From b5edffd86c81f61a0c8938034adacc777d88a2da Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 18:12:06 -0400 Subject: [PATCH 01/11] Add sample-data resolver for example datasets (cow.g, etc.) VTK examples reference data files by bare name; the bytes live in VTK's content-addressed ExternalData store. Add vtk_prompt/data/resolver.py, which builds a basename -> sha512 index from a local VTK data tree (VTK_PROMPT_DATA_ROOT) and resolves a name by fetching the blob from data.kitware.com, caching it under the user cache dir and verifying the checksum. Stdlib only (no new dependency). resolve(name) returns a local path (or None if unknown / fetch fails); available_names() and has_data_root() support later UI/prompt hints. Next: stage resolved files into the exec environment so generated example code runs as-is. --- src/vtk_prompt/data/__init__.py | 5 ++ src/vtk_prompt/data/resolver.py | 123 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/vtk_prompt/data/__init__.py create mode 100644 src/vtk_prompt/data/resolver.py diff --git a/src/vtk_prompt/data/__init__.py b/src/vtk_prompt/data/__init__.py new file mode 100644 index 0000000..9dee7a0 --- /dev/null +++ b/src/vtk_prompt/data/__init__.py @@ -0,0 +1,5 @@ +"""Sample-data resolution for example-style prompts that read files.""" + +from .resolver import available_names, has_data_root, resolve + +__all__ = ["available_names", "has_data_root", "resolve"] diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py new file mode 100644 index 0000000..3158ca8 --- /dev/null +++ b/src/vtk_prompt/data/resolver.py @@ -0,0 +1,123 @@ +"""Resolve named VTK example datasets to local file paths. + +VTK examples reference data files by bare name (for example ``cow.g``). The bytes +live in VTK's content-addressed ExternalData store; each file has a +``.sha512`` pointer somewhere in a VTK data tree. This module builds a +``basename -> sha512`` index from a local VTK data tree (pointed at by the +``VTK_PROMPT_DATA_ROOT`` environment variable), then resolves a name by fetching +the blob from the ExternalData store, caching it locally and verifying its +checksum. That lets generated example-style code which reads such files run. +""" + +import hashlib +import logging +import os +import shutil +import urllib.request +from pathlib import Path +from urllib.error import URLError + +logger = logging.getLogger(__name__) + +_STORE_URL = "https://data.kitware.com/api/v1/file/hashsum/sha512/{digest}/download" +_DATA_ROOT_ENV = "VTK_PROMPT_DATA_ROOT" +_POINTER_SUFFIX = ".sha512" + +_index_cache: dict[str, str] | None = None + + +def _cache_dir() -> Path: + base = os.environ.get("XDG_CACHE_HOME") + root = Path(base) if base else Path.home() / ".cache" + directory = root / "vtk-prompt" / "data" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _data_root() -> Path | None: + root = os.environ.get(_DATA_ROOT_ENV) + if root and Path(root).is_dir(): + return Path(root) + return None + + +def _build_index(root: Path) -> dict[str, str]: + index: dict[str, str] = {} + for dirpath, _dirs, files in os.walk(root): + for filename in files: + if not filename.endswith(_POINTER_SUFFIX): + continue + name = filename[: -len(_POINTER_SUFFIX)] + if name in index: + continue # first occurrence wins on duplicate basenames + try: + digest = (Path(dirpath) / filename).read_text().strip() + except OSError: + continue + if digest: + index[name] = digest + return index + + +def _load_index() -> dict[str, str]: + global _index_cache + if _index_cache is None: + root = _data_root() + _index_cache = _build_index(root) if root else {} + return _index_cache + + +def _sha512(path: Path) -> str: + digest = hashlib.sha512() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _download(digest: str, dest: Path) -> bool: + url = _STORE_URL.format(digest=digest) + try: + with urllib.request.urlopen(url, timeout=60) as response, open(dest, "wb") as out: + shutil.copyfileobj(response, out) + return True + except (OSError, URLError) as exc: + logger.warning("Could not download %s: %s", dest.name, exc) + dest.unlink(missing_ok=True) + return False + + +def resolve(name: str) -> str | None: + """Return a local path for a named dataset, downloading and caching as needed. + + Returns None if the name is unknown (not in the data index) or the download + or checksum verification fails. + """ + name = os.path.basename((name or "").strip()) + if not name: + return None + digest = _load_index().get(name) + cached = _cache_dir() / name + + if cached.exists() and (not digest or _sha512(cached) == digest): + return str(cached) + if not digest: + return None + + if not _download(digest, cached): + return None + if _sha512(cached) != digest: + logger.warning("Checksum mismatch for %s; discarding", name) + cached.unlink(missing_ok=True) + return None + return str(cached) + + +def available_names() -> list[str]: + """Return the sorted list of dataset names known to the resolver.""" + return sorted(_load_index().keys()) + + +def has_data_root() -> bool: + """Whether a local VTK data tree is configured (enables name resolution).""" + return _data_root() is not None From 3dea6a2b8b177a001d69539eefb0e526f2be435b Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 18:28:18 -0400 Subject: [PATCH 02/11] Auto-stage resolved data files so example code runs as written Before executing generated VTK code, rewrite bare data-file references (string literals that are a known dataset basename, e.g. 'cow.g') to their resolved local paths via the sample-data resolver. Only the executed copy is rewritten; the stored/displayed code keeps the bare names, so the editor and Run-state cue are unaffected. No-op unless a VTK data tree is configured (VTK_PROMPT_DATA_ROOT), and explicit paths / unrelated strings are left untouched. Verified end to end: a snippet reading 'cow.g' now resolves, downloads, and renders (2903 points) instead of failing on a missing file. --- src/vtk_prompt/controllers/generation.py | 8 ++++++- src/vtk_prompt/data/resolver.py | 30 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index f0569ad..10374f1 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -207,7 +207,13 @@ async def generate_and_execute_code(app: Any) -> None: def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None]: """Execute VTK code with our renderer. Returns (success, error_message).""" - success, error_message = execute_vtk_code(code_string, app.renderer, app.render_window) + # Resolve bare data-file references (e.g. 'cow.g') to fetched local paths so + # example-style code runs. No-op unless a VTK data tree is configured. Only + # the executed copy is rewritten; the stored/displayed code keeps bare names. + from ..data.resolver import stage_code + + exec_code = stage_code(code_string) + success, error_message = execute_vtk_code(exec_code, app.renderer, app.render_window) if not success and error_message: app.state.error_message = error_message diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py index 3158ca8..dc8dc77 100644 --- a/src/vtk_prompt/data/resolver.py +++ b/src/vtk_prompt/data/resolver.py @@ -12,6 +12,7 @@ import hashlib import logging import os +import re import shutil import urllib.request from pathlib import Path @@ -121,3 +122,32 @@ def available_names() -> list[str]: def has_data_root() -> bool: """Whether a local VTK data tree is configured (enables name resolution).""" return _data_root() is not None + + +# Match single- or double-quoted string literals with no embedded quote/newline. +_LITERAL_RE = re.compile(r"([\'\"])([^\'\"\n]+?)\1") + + +def stage_code(code: str) -> str: + """Rewrite bare data-file references in code to resolved local paths. + + Only string literals that are a bare filename (no directory separator) whose + basename is a known dataset are rewritten, so example code like + ``reader.SetFileName('cow.g')`` runs against the fetched file. Explicit paths + and unrelated strings are left untouched. + """ + index = _load_index() + if not index or not code: + return code + + def _replace(match: "re.Match[str]") -> str: + quote, value = match.group(1), match.group(2) + if ("/" in value) or ("\\" in value): + return match.group(0) + if value in index: + path = resolve(value) + if path: + return f"{quote}{path}{quote}" + return match.group(0) + + return _LITERAL_RE.sub(_replace, code) From 88d389d2f9f9348b141b12b6e776490942261405 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 20:56:19 -0400 Subject: [PATCH 03/11] Show referenced data files as chips in the code panel Surface the datasets the current code references as small chips in the "Generated Code" title bar. Each chip shows the dataset name with an icon for fetched vs not-yet-downloaded, and a hover tooltip with the local cache path, status, and source. Recomputed on every code change via a pure index lookup (no download); only shown when the code references known datasets. resolver.referenced()/artifacts() do the lookup; data_artifacts state drives the chips. --- src/vtk_prompt/data/__init__.py | 4 ++-- src/vtk_prompt/data/resolver.py | 29 ++++++++++++++++++++++++ src/vtk_prompt/state/initializer.py | 1 + src/vtk_prompt/ui/layout/content.py | 35 +++++++++++++++++++++++++++++ src/vtk_prompt/vtk_prompt_ui.py | 5 +++++ 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/vtk_prompt/data/__init__.py b/src/vtk_prompt/data/__init__.py index 9dee7a0..dec1cba 100644 --- a/src/vtk_prompt/data/__init__.py +++ b/src/vtk_prompt/data/__init__.py @@ -1,5 +1,5 @@ """Sample-data resolution for example-style prompts that read files.""" -from .resolver import available_names, has_data_root, resolve +from .resolver import artifacts, available_names, has_data_root, referenced, resolve -__all__ = ["available_names", "has_data_root", "resolve"] +__all__ = ["artifacts", "available_names", "has_data_root", "referenced", "resolve"] diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py index dc8dc77..eb8582e 100644 --- a/src/vtk_prompt/data/resolver.py +++ b/src/vtk_prompt/data/resolver.py @@ -151,3 +151,32 @@ def _replace(match: "re.Match[str]") -> str: return match.group(0) return _LITERAL_RE.sub(_replace, code) + + +def referenced(code: str) -> list[str]: + """Return dataset names referenced as bare string literals in code (no fetch).""" + index = _load_index() + if not index or not code: + return [] + found: list[str] = [] + for match in _LITERAL_RE.finditer(code): + value = match.group(2) + if ("/" in value) or ("\\" in value): + continue + if value in index and value not in found: + found.append(value) + return found + + +def artifacts(code: str) -> list[dict]: + """Return data artifacts referenced by code (name, cache path, fetched flag). + + Pure lookup (no download); used to surface what files the current code pulls + in and where they live. + """ + cache = _cache_dir() + result: list[dict] = [] + for name in referenced(code): + path = cache / name + result.append({"name": name, "path": str(path), "cached": path.exists()}) + return result diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 63a198c..54e8323 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -27,6 +27,7 @@ def initialize_state(app: Any) -> None: app.state.generated_code = "" app.state.generated_explanation = "" app.state.current_prompt = "" # the sent prompt shown inline with the explanation + app.state.data_artifacts = [] # datasets referenced by the current code # Code currently shown in the 3D view (last successful render); used to # disable Run when the editor already matches what is rendered. app.state.rendered_code = "" diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index be73b70..58feb13 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -29,6 +29,41 @@ def build_content(layout: Any, app: Any) -> None: with vuetify.VCardTitle( "Generated Code", classes="d-flex align-center" ): + # Data files this code references, with where they live. + with html.Div( + classes="d-flex align-center ml-3", + v_show="data_artifacts.length > 0", + ): + with vuetify.VTooltip( + v_for="a in data_artifacts", + key="a.name", + location="bottom", + ): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VChip( + "{{ a.name }}", + v_bind="props", + size="small", + variant="tonal", + classes="mr-1", + color=("a.cached ? 'success' : 'primary'", "primary"), + prepend_icon=( + "a.cached ? 'mdi-file-check'" + + " : 'mdi-cloud-download-outline'", + "mdi-file-outline", + ), + ) + with html.Div(): + html.Div( + "{{ a.cached ? 'Fetched' :" + + " 'Will download on run' }}", + classes="font-weight-medium", + ) + html.Div("{{ a.path }}", classes="text-caption") + html.Div( + "Source: VTK data store", + classes="text-caption text-medium-emphasis", + ) vuetify.VSpacer() # Undo across code versions (generations, runs, edits) with vuetify.VTooltip(text="Undo code change", location="bottom"): diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 93daddf..40b23e2 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -362,6 +362,11 @@ def _on_sessions_sort_change(self, **_: Any) -> None: @change("generated_code") def _on_generated_code_change(self, **_: Any) -> None: """Debounce-snapshot manual edits so undo/redo can step through them.""" + # Surface the data files the current code references (pure index lookup). + from .data.resolver import artifacts + + self.state.data_artifacts = artifacts(self.state.generated_code) + if self._snapshot_task is not None and not self._snapshot_task.done(): self._snapshot_task.cancel() try: From 0feefc206029babccfc2424c8cc5b3742b97d411 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 10:04:58 -0400 Subject: [PATCH 04/11] Pinpoint the failing line when run code errors execute_vtk_code now captures the line where execution failed (handling both syntax errors and runtime tracebacks) and returns its source text. Because the executed code differs from what the editor shows (explanation banner prepended, markdown fence stripped, import added, data literals rewritten), the caller matches on the line's text rather than its number to find the editor line, then prefixes the run error with "Line N: ". Returns the offending text up the stack so a later pass can mark the line in the editor. --- src/vtk_prompt/controllers/generation.py | 28 +++++++++++++++++++++-- src/vtk_prompt/rendering/code_executor.py | 27 +++++++++++++++++++--- tests/test_executor_guarded_main.py | 2 +- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 10374f1..835afba 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -205,6 +205,26 @@ async def generate_and_execute_code(app: Any) -> None: app.state.flush() # push final state (result/error, spinner off) to client +def _editor_line_for(displayed_code: str, line_text: str | None) -> int: + """Find the 1-based line of ``line_text`` in the code shown in the editor.""" + if not line_text: + return 0 + target = line_text.strip() + for i, line in enumerate(displayed_code.splitlines(), start=1): + if line.strip() == target: + return i + return 0 + + +def _format_exec_error(displayed_code: str, error_message: str, line_text: str | None) -> str: + """Prefix a run error with the editor line and the offending source text.""" + if not line_text: + return error_message + editor_line = _editor_line_for(displayed_code, line_text) + where = f"Line {editor_line}: {line_text}" if editor_line else f"At: {line_text}" + return f"{where}\n{error_message}" + + def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None]: """Execute VTK code with our renderer. Returns (success, error_message).""" # Resolve bare data-file references (e.g. 'cow.g') to fetched local paths so @@ -213,10 +233,14 @@ def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None] from ..data.resolver import stage_code exec_code = stage_code(code_string) - success, error_message = execute_vtk_code(exec_code, app.renderer, app.render_window) + success, error_message, error_line_text = execute_vtk_code( + exec_code, app.renderer, app.render_window + ) if not success and error_message: - app.state.error_message = error_message + app.state.error_message = _format_exec_error( + code_string, error_message, error_line_text + ) if success: app.state.rendered_code = code_string diff --git a/src/vtk_prompt/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py index 95738bb..12654bd 100644 --- a/src/vtk_prompt/rendering/code_executor.py +++ b/src/vtk_prompt/rendering/code_executor.py @@ -1,5 +1,7 @@ """VTK Code Execution Module.""" +import traceback + import vtk from .. import get_logger @@ -10,7 +12,7 @@ def execute_vtk_code( code_string: str, renderer: vtk.vtkRenderer, render_window: vtk.vtkRenderWindow -) -> tuple[bool, str | None]: +) -> tuple[bool, str | None, str | None]: """Execute VTK code with renderer context. Clears previous actors, cleans the code string, executes it with the renderer @@ -49,9 +51,28 @@ def execute_vtk_code( except Exception as render_error: logger.warning("Render error: %s", render_error) - return True, None + return True, None, None except Exception as e: error_message = f"Error executing code: {str(e)}" logger.error(error_message) - return False, error_message + # Identify the offending line *within the executed code* and return its + # text (not its number). The executed code differs from what the editor + # shows: an explanation banner is prepended, a markdown fence may be + # stripped, an import may be added, and data literals may be rewritten. + # The caller matches on text to locate the editor line robustly. + line_text = None + segment = locals().get("code_segment") + if isinstance(segment, str): + lineno = None + if isinstance(e, SyntaxError) and e.lineno: + lineno = e.lineno + else: + for frame, frame_line in traceback.walk_tb(e.__traceback__): + if frame.f_code.co_filename == "": + lineno = frame_line + if lineno is not None: + lines = segment.splitlines() + if 1 <= lineno <= len(lines): + line_text = lines[lineno - 1].strip() + return False, error_message, line_text diff --git a/tests/test_executor_guarded_main.py b/tests/test_executor_guarded_main.py index f58628d..517198f 100644 --- a/tests/test_executor_guarded_main.py +++ b/tests/test_executor_guarded_main.py @@ -46,6 +46,6 @@ def test_guarded_main_script_adds_actor_to_renderer(): except Exception: # pragma: no cover - no VTK rendering backend available pytest.skip("VTK render window unavailable in this environment") - ok, err = execute_vtk_code(GUARDED_SPHERE, renderer, render_window) + ok, err, _ = execute_vtk_code(GUARDED_SPHERE, renderer, render_window) assert ok, err assert renderer.GetActors().GetNumberOfItems() == 1 From e8942e897beb4f6a2c390b6e6ae89271fdd300ac Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 11:40:04 -0400 Subject: [PATCH 05/11] Tell the model about vtk-mcp tools when the server is reachable Add a conditional tool_use prompt component that instructs the model to use the vtk-mcp reference tools (search/confirm classes and signatures, check input/output types, validate before finalizing, adapt examples) instead of relying on memory for uncommon APIs. assemble_vtk_prompt gains an mcp_active flag and the client passes bool(mcp_client), so the guidance (and its token cost) is included only when a vtk-mcp server is actually reachable. Without mcp, prompts are unchanged. --- src/vtk_prompt/client.py | 1 + src/vtk_prompt/prompts/components/tool_use.yml | 15 +++++++++++++++ .../prompts/prompt_component_assembler.py | 3 +++ 3 files changed, 19 insertions(+) create mode 100644 src/vtk_prompt/prompts/components/tool_use.yml diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index 2c6ee86..1f9b9d2 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -397,6 +397,7 @@ def query( request=message, ui_mode=ui_mode, context_snippets=context_snippets, + mcp_active=bool(mcp_client), VTK_VERSION=VTK_VERSION, PYTHON_VERSION=PYTHON_VERSION, ) diff --git a/src/vtk_prompt/prompts/components/tool_use.yml b/src/vtk_prompt/prompts/components/tool_use.yml new file mode 100644 index 0000000..96182e8 --- /dev/null +++ b/src/vtk_prompt/prompts/components/tool_use.yml @@ -0,0 +1,15 @@ +role: assistant +content: | + You have access to VTK reference tools provided by a vtk-mcp server. Use them to + ground your code rather than relying on memory for uncommon or uncertain APIs: + - Find and confirm classes: search for the right class when unsure which to use, + and verify exact class names, method names, and signatures for anything you are + not fully certain about. + - Check data flow: confirm a filter's expected input and output types before + wiring it into a pipeline. + - Validate before finalizing: validate the generated code and fix any reported + invalid class names, methods, or imports. + - Adapt examples: when helpful, retrieve a relevant VTK example and base your code + on it. + For simple, well-known operations you are confident about, answer directly without + tools. Prefer tool-verified facts over assumptions for anything uncommon. diff --git a/src/vtk_prompt/prompts/prompt_component_assembler.py b/src/vtk_prompt/prompts/prompt_component_assembler.py index 6cde261..f024f88 100644 --- a/src/vtk_prompt/prompts/prompt_component_assembler.py +++ b/src/vtk_prompt/prompts/prompt_component_assembler.py @@ -197,6 +197,7 @@ def assemble_vtk_prompt( request: str, ui_mode: bool = False, context_snippets: str | None = None, + mcp_active: bool = False, **variables: Any, ) -> PromptData: """Assemble VTK prompt from file-based components. @@ -205,6 +206,7 @@ def assemble_vtk_prompt( request: User's request text ui_mode: Whether to include UI-specific instructions context_snippets: Optional context snippets from vtk-mcp (enables rag_context component) + mcp_active: Whether a vtk-mcp server is reachable (enables tool_use guidance) **variables: Additional variables for substitution Returns: @@ -218,6 +220,7 @@ def assemble_vtk_prompt( assembler.add_component("vtk_instructions") # Conditional components (order matters for message composition) + assembler.add_if(mcp_active, "tool_use") assembler.add_if(bool(context_snippets), "rag_context") assembler.add_if(ui_mode, "ui_renderer") From 5995605802f56d0dc052afa68a1d68ec95611ad1 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 12:10:27 -0400 Subject: [PATCH 06/11] Stop generated code from killing the app via sys.exit/argparse VTK example scripts (e.g. the PLOT3D streamlines example) are standalone CLIs: they define get_program_parameters() with argparse and run via a main() guard. The executor runs code with __name__ == "__main__", so that main() fires, parse_args() parses the app's own argv, fails, and calls sys.exit(). SystemExit is not an Exception subclass, so it escaped execute_vtk_code's handler and tore down the whole trame app whenever such code was run or revisited. Trap SystemExit alongside Exception in the executor and report it as a normal code error with an actionable message (the code is a standalone script using argparse/sys.exit). The line-pinpointing added earlier correctly flags the parse_args() line. KeyboardInterrupt is intentionally not caught. --- src/vtk_prompt/rendering/code_executor.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vtk_prompt/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py index 12654bd..2be9aaa 100644 --- a/src/vtk_prompt/rendering/code_executor.py +++ b/src/vtk_prompt/rendering/code_executor.py @@ -53,8 +53,19 @@ def execute_vtk_code( return True, None, None - except Exception as e: - error_message = f"Error executing code: {str(e)}" + except (Exception, SystemExit) as e: + # SystemExit is NOT an Exception subclass: generated code that calls + # sys.exit() or argparse.parse_args() (common in VTK example scripts that + # read command-line data files) would otherwise propagate out and kill the + # whole trame app. Trap it here and report it as a normal code error. + if isinstance(e, SystemExit): + error_message = ( + "Error executing code: the generated code runs as a standalone " + "script (it uses argparse/sys.exit and expects command-line " + "arguments), so it cannot run inside the app as written." + ) + else: + error_message = f"Error executing code: {str(e)}" logger.error(error_message) # Identify the offending line *within the executed code* and return its # text (not its number). The executed code differs from what the editor From 5bdc9099b16ed6ae4dae5d3806f7ce484ee5fa3f Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 17:51:19 -0400 Subject: [PATCH 07/11] Move conversation file ops to Recents; make downloads actually work The Settings "Files" tab was a leftover from before the sessions model: it had a generic upload plus two download buttons wired to JS functions (download_conversation_file /download_prompt_file) that never existed, so nothing happened on click. Put the operations where they belong: - Per-conversation Export in the Recents row menu, via a new export_session trigger that returns that session's JSON by id. - Import in the Recents header (a dialog accepting .json), reusing the existing uploaded-files routing. - The old Files tab becomes Config: import a prompt/config YAML, and a working "Download Config File" button. Conversation upload/download removed from here. Adds the missing client-side download helpers in utils.js (Blob download plus exportSession/exportConfig that call the server triggers). The "prompt file" download was always the settings YAML (save_config), so it stays a config concern. --- src/vtk_prompt/controllers/sessions.py | 11 ++++ .../ui/layout/conversation_history.py | 31 +++++++++ src/vtk_prompt/ui/layout/settings_dialog.py | 63 +++++++------------ src/vtk_prompt/utils.js | 28 +++++++++ src/vtk_prompt/vtk_prompt_ui.py | 5 ++ 5 files changed, 99 insertions(+), 39 deletions(-) diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index de764a0..07929a6 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -242,6 +242,17 @@ def toggle_pin_session(app: Any, session_id: str) -> None: refresh_sessions_list(app) +def export_session(app: Any, session_id: str) -> str: + """Return a session's persisted JSON for download (empty string if unknown).""" + if session_id == (getattr(app.state, "current_session_id", "") or ""): + capture_current_session(app) # fold in any unsaved live messages first + sess = _sessions(app).get(session_id) + if not sess: + return "" + data = {key: sess.get(key) for key in _PERSIST_KEYS} + return json.dumps(data, indent=2) + + def _sessions_dir() -> Path: """Directory holding one JSON file per persisted session.""" directory = _config_home() / "sessions" diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 0a30be2..41980e9 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -20,6 +20,16 @@ def _header(app: Any) -> None: disabled=("conversation_navigation.length === 0", True), v_bind="props", ) + with vuetify.VTooltip(text="Import conversation", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-tray-arrow-up", + click="import_dialog = true", + variant="text", + density="compact", + color="primary", + v_bind="props", + ) with vuetify.VTooltip(text="Toggle sort order", location="bottom"): with vuetify.Template(v_slot_activator="{ props }"): vuetify.VBtn( @@ -57,6 +67,10 @@ def _row_menu(app: Any) -> None: click="rename_target_id = s.id; rename_text = s.title; rename_dialog = true" ): vuetify.VListItemTitle("Rename") + with vuetify.VListItem( + click="window.trame.utils.vtk_prompt.exportSession(s.id, s.title)" + ): + vuetify.VListItemTitle("Export") with vuetify.VListItem( click=( "delete_target_id = s.id; delete_target_title = s.title;" @@ -88,6 +102,23 @@ def _dialogs(app: Any) -> None: color="primary", variant="text", ) + # Import + with vuetify.VDialog(v_model=("import_dialog", False), max_width="480"): + with vuetify.VCard(): + vuetify.VCardTitle("Import conversation") + with vuetify.VCardText(): + vuetify.VFileUpload( + label="Choose a .json conversation file", + v_model=("uploaded_files", None), + accept=".json", + multiple=True, + hide_details="auto", + density="compact", + color="teal-lighten-5", + ) + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn("Close", click="import_dialog = false", variant="text") # Delete with vuetify.VDialog(v_model=("delete_dialog", False), max_width="420"): with vuetify.VCard(): diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index 71c6742..ac7ddd1 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -24,35 +24,28 @@ def build_settings_dialog(layout: Any, app: Any) -> None: color="primary", classes="pa-1", ): - vuetify.VTab("Files", value="files") + vuetify.VTab("Config", value="files") vuetify.VTab("Model", value="model") vuetify.VTab("Advanced", value="advanced") with vuetify.VTabsWindow(v_model=("active_settings_tab", "files")): # Files Tab with vuetify.VTabsWindowItem(value="files"): with vuetify.VCard(): - with vuetify.VCardTitle("Uploads"): + with vuetify.VCardTitle("Import config"): vuetify.VCardSubtitle( - "Upload conversation files (.json) or prompt " - "config files (.yaml/.yml)" + "Load a prompt/config file (.yaml/.yml). To import " + "a conversation, use Import in Recents." ) with vuetify.VCardText(): - with vuetify.VTooltip( - text="Upload conversation files (.json) or prompt " - "config files (.yaml/.yml)", - location="top", - ): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VFileUpload( - label="Upload Files (.json, .yaml, .yml)", - v_model=("uploaded_files", None), - accept=".json,.yaml,.yml", - multiple=True, - hide_details="auto", - classes="py-3 pr-1 mr-1 w-100", - v_bind="props", - color="teal-lighten-5", - ) + vuetify.VFileUpload( + label="Choose a config file (.yaml, .yml)", + v_model=("uploaded_files", None), + accept=".yaml,.yml", + multiple=True, + hide_details="auto", + classes="py-3 pr-1 mr-1 w-100", + color="teal-lighten-5", + ) with vuetify.VCard(): with vuetify.VCardTitle("Settings"): vuetify.VCardSubtitle("Configure default behavior") @@ -65,26 +58,18 @@ def build_settings_dialog(layout: Any, app: Any) -> None: hide_details=True, ) with vuetify.VCard(): - with vuetify.VCardTitle("Downloads"): - vuetify.VCardSubtitle("Download conversation or prompt files") + with vuetify.VCardTitle("Export config"): + vuetify.VCardSubtitle( + "Download the current settings as a config file" + ) with vuetify.VCardText(): - with vuetify.VRow(cols=12): - with vuetify.VCol(cols=6): - vuetify.VBtn( - "Download Conversation File", - color="secondary", - classes="mr-2 mt-2 w-100", - click="download_conversation_file", - append_icon="mdi-download", - ) - with vuetify.VCol(cols=6): - vuetify.VBtn( - "Download Prompt File", - color="secondary", - classes="mr-2 mt-2 w-100", - click="download_prompt_file", - append_icon="mdi-download", - ) + vuetify.VBtn( + "Download Config File", + color="secondary", + classes="mt-2 w-100", + click="window.trame.utils.vtk_prompt.exportConfig()", + append_icon="mdi-download", + ) # Model Tab with vuetify.VTabsWindowItem(value="model"): # Tab Navigation - Centered diff --git a/src/vtk_prompt/utils.js b/src/vtk_prompt/utils.js index 5cbfeae..e019583 100644 --- a/src/vtk_prompt/utils.js +++ b/src/vtk_prompt/utils.js @@ -10,4 +10,32 @@ window.trame.utils.vtk_prompt = { return true; }, }, + + download(name, text, mime) { + const blob = new Blob([text], { type: mime || "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, + + sanitize(name) { + return (name || "conversation").replace(/[^\w.-]+/g, "_").slice(0, 80); + }, + + async exportSession(id, title) { + const text = await window.trame.trigger("export_session", [id]); + if (text) { + this.download(this.sanitize(title) + ".json", text, "application/json"); + } + }, + + async exportConfig() { + const text = await window.trame.trigger("save_config"); + if (text) this.download("vtk-prompt-config.yaml", text, "text/yaml"); + }, }; diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 40b23e2..faa59f9 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -344,6 +344,11 @@ def save_conversation(self) -> str: """Save current conversation history as JSON string.""" return conversation.save_conversation(self) + @trigger("export_session") + def export_session(self, session_id: str) -> str: + """Return one session's JSON for download (called from utils.js).""" + return sessions.export_session(self, session_id) + @trigger("save_config") def save_config(self) -> str: """Save current configuration as YAML string for download.""" From c441964fa2889043f4772bb8f946e4f2ac9469a5 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 18:04:17 -0400 Subject: [PATCH 08/11] Add a Settings field for the sample-data location The resolver only read VTK_PROMPT_DATA_ROOT from the environment, so the sample- data feature was unusable without setting an env var at launch. Make the data root runtime-settable: resolver.set_data_root() installs an override and clears the cached index so the next lookup rebuilds against the new tree (env var stays the fallback). Wire it through: a "Sample data location" field in the Settings Config tab (bound to data_root), an initializer default from the env var, a change handler that repoints the resolver and refreshes the referenced-file chips live, and config round-tripping (save_config exports data_root; a loaded config applies it). Like the other settings, the field is session/config-scoped, not auto-saved to disk. --- src/vtk_prompt/controllers/configuration.py | 2 ++ src/vtk_prompt/data/resolver.py | 16 ++++++++++++++++ src/vtk_prompt/state/initializer.py | 6 ++++++ src/vtk_prompt/ui/layout/settings_dialog.py | 18 ++++++++++++++++++ src/vtk_prompt/utils/prompt_loader.py | 6 ++++++ src/vtk_prompt/vtk_prompt_ui.py | 8 ++++++++ 6 files changed, 56 insertions(+) diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py index 20bab0e..601841b 100644 --- a/src/vtk_prompt/controllers/configuration.py +++ b/src/vtk_prompt/controllers/configuration.py @@ -44,6 +44,7 @@ def save_config(app: Any) -> str: max_tokens = int(getattr(app.state, "max_tokens", 1000)) retries = int(getattr(app.state, "retry_attempts", 1)) mcp_url = getattr(app.state, "mcp_url", "").strip() + data_root = getattr(app.state, "data_root", "").strip() top_k = int(getattr(app.state, "top_k", 5)) base_url = getattr(app.state, "local_base_url", "").strip() if not use_cloud else "" @@ -53,6 +54,7 @@ def save_config(app: Any) -> str: "model": provider_model, "base_url": base_url, "mcp_url": mcp_url, + "data_root": data_root, "top_k": top_k, "retries": retries, "modelParameters": { diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py index eb8582e..ac8c696 100644 --- a/src/vtk_prompt/data/resolver.py +++ b/src/vtk_prompt/data/resolver.py @@ -25,6 +25,20 @@ _POINTER_SUFFIX = ".sha512" _index_cache: dict[str, str] | None = None +_data_root_override: Path | None = None + + +def set_data_root(path: str | None) -> None: + """Point the resolver at a VTK data tree at runtime (the Settings field). + + A falsy value clears the override and falls back to the VTK_PROMPT_DATA_ROOT + environment variable. Always invalidates the cached index so the next lookup + rebuilds against the new root. + """ + global _data_root_override, _index_cache + cleaned = (path or "").strip() + _data_root_override = Path(cleaned) if cleaned else None + _index_cache = None def _cache_dir() -> Path: @@ -36,6 +50,8 @@ def _cache_dir() -> Path: def _data_root() -> Path | None: + if _data_root_override is not None and _data_root_override.is_dir(): + return _data_root_override root = os.environ.get(_DATA_ROOT_ENV) if root and Path(root).is_dir(): return Path(root) diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 54e8323..8c7b2f2 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -28,6 +28,12 @@ def initialize_state(app: Any) -> None: app.state.generated_explanation = "" app.state.current_prompt = "" # the sent prompt shown inline with the explanation app.state.data_artifacts = [] # datasets referenced by the current code + # Sample-data resolver root: defaults to the env var, overridable in Settings. + import os as _os + from ..data.resolver import set_data_root as _set_data_root + + app.state.data_root = _os.environ.get("VTK_PROMPT_DATA_ROOT", "") + _set_data_root(app.state.data_root) # Code currently shown in the 3D view (last successful render); used to # disable Run when the editor already matches what is rendered. app.state.rendered_code = "" diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index ac7ddd1..dd3d56b 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -70,6 +70,24 @@ def build_settings_dialog(layout: Any, app: Any) -> None: click="window.trame.utils.vtk_prompt.exportConfig()", append_icon="mdi-download", ) + with vuetify.VCard(): + with vuetify.VCardTitle("Sample data"): + vuetify.VCardSubtitle( + "Local VTK data tree used to resolve example " + "datasets by name (e.g. cow.g)" + ) + with vuetify.VCardText(): + vuetify.VTextField( + label="Sample data location", + v_model=("data_root", ""), + placeholder="/path/to/VTK/Testing/Data", + hint="Folder of .sha512 data pointers; blank uses " + "the VTK_PROMPT_DATA_ROOT environment variable", + persistent_hint=True, + density="compact", + hide_details="auto", + clearable=True, + ) # Model Tab with vuetify.VTabsWindowItem(value="model"): # Tab Navigation - Centered diff --git a/src/vtk_prompt/utils/prompt_loader.py b/src/vtk_prompt/utils/prompt_loader.py index d8a7627..668d89a 100644 --- a/src/vtk_prompt/utils/prompt_loader.py +++ b/src/vtk_prompt/utils/prompt_loader.py @@ -124,6 +124,12 @@ def _process_rag_and_generation_settings(app: Any) -> None: _base = app.custom_prompt_data.get("base_url") if isinstance(_base, str) and _base.strip(): app.state.local_base_url = _base.strip() + if "data_root" in app.custom_prompt_data: + _dr = app.custom_prompt_data.get("data_root") + if isinstance(_dr, str): + app.state.data_root = _dr.strip() + from ..data.resolver import set_data_root + set_data_root(app.state.data_root) def _process_model_parameters(app: Any) -> None: diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index faa59f9..341fdec 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -359,6 +359,14 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) + @change("data_root") + def _on_data_root_change(self, data_root, **_: Any) -> None: + """Repoint the sample-data resolver and refresh referenced-file chips.""" + from .data.resolver import artifacts, set_data_root + + set_data_root(data_root or "") + self.state.data_artifacts = artifacts(self.state.generated_code) + @change("history_sort_order") def _on_sessions_sort_change(self, **_: Any) -> None: """Re-render the Recents list when its sort order changes.""" From 878644b66c26a4c1d6c2c8b890edc2f91330816b Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 18:36:36 -0400 Subject: [PATCH 09/11] Rework the settings dialog into one coherent form The dialog read as a stack of competing cards with inconsistent spacing. Fixes: - Flatten each tab to a single surface with quiet section labels (text-overline) and dividers instead of nested cards with bold titles/subtitles. - Replace free-floating description text (which overlapped the next field's floating label) with proper persistent field hints, plus consistent spacing between fields. This was the main "looks broken" issue on Model/Advanced. - Replace emoji (cloud/house tabs, plug, gear, thermometer) with mdi icons to match the rest of the app; the Cloud/Local tabs now use mdi-cloud-outline and mdi-laptop. - Temperature slider: recolor orange -> primary, drop the always-on thumb label (it collided with the heading) in favor of an inline value, and give it room. - Config tab: swap the oversized drag-and-drop zone for a compact file input and calm the full-width filled download button down to a small outlined one. - Drop the unaligned per-field prepend icons for a clean left edge. --- src/vtk_prompt/ui/layout/settings_dialog.py | 436 ++++++++++---------- 1 file changed, 217 insertions(+), 219 deletions(-) diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index dd3d56b..004fba0 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -7,241 +7,239 @@ from typing import Any +from trame.widgets import html from trame.widgets import vuetify3 as vuetify from ...provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER vuetify.enable_lab() +_LABEL = "text-overline text-medium-emphasis d-block mb-1" +_DESC = "text-caption text-medium-emphasis d-block mb-3" + + +def _section(title: str) -> None: + html.Div(title, classes=_LABEL) + def build_settings_dialog(layout: Any, app: Any) -> None: """Build the advanced settings dialog with configuration options.""" with layout.content: - with vuetify.VDialog(v_model=("advanced_settings_open", False), classes="w-33"): + with vuetify.VDialog(v_model=("advanced_settings_open", False), max_width="560"): with vuetify.VCard(): with vuetify.VTabs( v_model=("active_settings_tab", "files"), color="primary", - classes="pa-1", + classes="px-2", ): vuetify.VTab("Config", value="files") vuetify.VTab("Model", value="model") vuetify.VTab("Advanced", value="advanced") + vuetify.VDivider() with vuetify.VTabsWindow(v_model=("active_settings_tab", "files")): - # Files Tab - with vuetify.VTabsWindowItem(value="files"): - with vuetify.VCard(): - with vuetify.VCardTitle("Import config"): - vuetify.VCardSubtitle( - "Load a prompt/config file (.yaml/.yml). To import " - "a conversation, use Import in Recents." - ) - with vuetify.VCardText(): - vuetify.VFileUpload( - label="Choose a config file (.yaml, .yml)", - v_model=("uploaded_files", None), - accept=".yaml,.yml", - multiple=True, - hide_details="auto", - classes="py-3 pr-1 mr-1 w-100", - color="teal-lighten-5", - ) - with vuetify.VCard(): - with vuetify.VCardTitle("Settings"): - vuetify.VCardSubtitle("Configure default behavior") - with vuetify.VCardText(): - vuetify.VCheckbox( - label="Automatically run new conversation files", - v_model=("auto_run_conversation_file", True), - density="compact", - color="primary", - hide_details=True, - ) - with vuetify.VCard(): - with vuetify.VCardTitle("Export config"): - vuetify.VCardSubtitle( - "Download the current settings as a config file" - ) - with vuetify.VCardText(): - vuetify.VBtn( - "Download Config File", - color="secondary", - classes="mt-2 w-100", - click="window.trame.utils.vtk_prompt.exportConfig()", - append_icon="mdi-download", - ) - with vuetify.VCard(): - with vuetify.VCardTitle("Sample data"): - vuetify.VCardSubtitle( - "Local VTK data tree used to resolve example " - "datasets by name (e.g. cow.g)" - ) - with vuetify.VCardText(): - vuetify.VTextField( - label="Sample data location", - v_model=("data_root", ""), - placeholder="/path/to/VTK/Testing/Data", - hint="Folder of .sha512 data pointers; blank uses " - "the VTK_PROMPT_DATA_ROOT environment variable", - persistent_hint=True, - density="compact", - hide_details="auto", - clearable=True, - ) - # Model Tab - with vuetify.VTabsWindowItem(value="model"): - # Tab Navigation - Centered - with vuetify.VRow(justify="center"): - with vuetify.VCol(cols="auto"): - with vuetify.VTabs( - v_model=("tab_index", 0), - color="primary", - slider_color="primary", - centered=True, - grow=False, - ): - vuetify.VTab("☁️ Cloud") - vuetify.VTab("🏠Local") - - # Tab Content - with vuetify.VTabsWindow(v_model="tab_index"): - # Cloud Providers Tab Content - with vuetify.VTabsWindowItem(): - with vuetify.VCard(flat=True, style="mt-2"): - with vuetify.VCardText(): - # Provider selection - vuetify.VSelect( - label="Provider", - v_model=("provider", DEFAULT_PROVIDER), - items=("available_providers", []), - density="compact", - variant="outlined", - prepend_icon="mdi-cloud", - ) - # Model selection - vuetify.VSelect( - label="Model", - v_model=("model", DEFAULT_MODEL), - items=("available_models[provider] || []",), - density="compact", - variant="outlined", - prepend_icon="mdi-brain", - ) - # API Token - vuetify.VTextField( - label="API Token", - v_model=("api_token", ""), - placeholder="Enter your API token", - type="password", - density="compact", - variant="outlined", - prepend_icon="mdi-key", - hint="Required for cloud providers", - persistent_hint=True, - error=("!api_token", False), - ) - - # Local Models Tab Content - with vuetify.VTabsWindowItem(): - with vuetify.VCard(flat=True, style="mt-2"): - with vuetify.VCardText(): - vuetify.VTextField( - label="Base URL", - v_model=( - "local_base_url", - "http://localhost:11434/v1", - ), - placeholder="http://localhost:11434/v1", - density="compact", - variant="outlined", - prepend_icon="mdi-server", - hint="Ollama, LM Studio, etc.", - persistent_hint=True, - ) - vuetify.VTextField( - label="Model Name", - v_model=("local_model", "devstral"), - placeholder="devstral", - density="compact", - variant="outlined", - prepend_icon="mdi-brain", - hint="Model identifier", - persistent_hint=True, - ) - # Optional API Token for local - vuetify.VTextField( - label="API Token (Optional)", - v_model=("api_token", "ollama"), - placeholder="ollama", - type="password", - density="compact", - variant="outlined", - prepend_icon="mdi-key", - hint="Optional for local servers", - persistent_hint=True, - ) - - # Advanced Settings Tab - with vuetify.VTabsWindowItem(value="advanced"): - # vtk-mcp Settings Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("🔌 vtk-mcp", classes="pb-0") - with vuetify.VCardText(): - vuetify.VTextField( - label="vtk-mcp Server URL", - v_model=("mcp_url", ""), - placeholder="http://localhost:8000", - clearable=True, - density="compact", - variant="outlined", - prepend_icon="mdi-bookshelf", - hint="Enables context retrieval and VTK API " - "validation; leave blank for baseline generation", - persistent_hint=True, - ) - vuetify.VTextField( - label="Top K", - v_model=("top_k", 5), - type="number", - min=1, - max=15, - density="compact", - disabled=("!mcp_url",), - variant="outlined", - prepend_icon="mdi-chart-scatter-plot", - ) - - # Generation Settings Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("⚙️ Generation Settings", classes="pb-0") - with vuetify.VCardText(): - vuetify.VSlider( - label="Temperature", - v_model=("temperature", 0.1), - min=0.0, - max=1.0, - step=0.1, - thumb_label="always", - color="orange", - prepend_icon="mdi-thermometer", - classes="mt-2", - disabled=("!temperature_supported",), - ) - vuetify.VTextField( - label="Max Tokens", - v_model=("max_tokens", 1000), - type="number", - density="compact", - variant="outlined", - prepend_icon="mdi-format-text", - ) - vuetify.VTextField( - label="Retry Attempts", - v_model=("retry_attempts", 3), - type="number", - min=1, - max=5, - density="compact", - variant="outlined", - prepend_icon="mdi-repeat", - ) + _config_tab() + _model_tab() + _advanced_tab() + + +def _config_tab() -> None: + with vuetify.VTabsWindowItem(value="files"): + with vuetify.VCardText(classes="pa-4"): + _section("Configuration") + html.Div( + "Import or export the model/prompt config (.yaml). To import a " + "conversation, use Import in Recents.", + classes=_DESC, + ) + vuetify.VFileInput( + label="Import config file (.yaml, .yml)", + v_model=("uploaded_files", None), + accept=".yaml,.yml", + multiple=True, + prepend_icon="", + prepend_inner_icon="mdi-upload", + density="compact", + variant="outlined", + hide_details=True, + classes="mb-3", + ) + vuetify.VBtn( + "Download config", + variant="outlined", + color="primary", + size="small", + prepend_icon="mdi-download", + click="window.trame.utils.vtk_prompt.exportConfig()", + ) + + vuetify.VDivider(classes="my-5") + _section("Behavior") + vuetify.VCheckbox( + label="Automatically run imported conversations", + v_model=("auto_run_conversation_file", True), + density="compact", + color="primary", + hide_details=True, + ) + + vuetify.VDivider(classes="my-5") + _section("Sample data") + html.Div( + "Local VTK data tree used to resolve example datasets by name " + "(e.g. cow.g).", + classes=_DESC, + ) + vuetify.VTextField( + label="Sample data location", + v_model=("data_root", ""), + placeholder="/path/to/VTK/Testing/Data", + hint="Folder of .sha512 data pointers; blank uses the " + "VTK_PROMPT_DATA_ROOT environment variable", + persistent_hint=True, + density="compact", + variant="outlined", + clearable=True, + ) + + +def _model_tab() -> None: + with vuetify.VTabsWindowItem(value="model"): + with vuetify.VCardText(classes="pa-4"): + with vuetify.VTabs( + v_model=("tab_index", 0), + color="primary", + density="compact", + classes="mb-4", + ): + vuetify.VTab("Cloud", prepend_icon="mdi-cloud-outline") + vuetify.VTab("Local", prepend_icon="mdi-laptop") + with vuetify.VTabsWindow(v_model="tab_index"): + with vuetify.VTabsWindowItem(): + vuetify.VSelect( + label="Provider", + v_model=("provider", DEFAULT_PROVIDER), + items=("available_providers", []), + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VSelect( + label="Model", + v_model=("model", DEFAULT_MODEL), + items=("available_models[provider] || []",), + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VTextField( + label="API token", + v_model=("api_token", ""), + placeholder="Enter your API token", + type="password", + density="compact", + variant="outlined", + hint="Required for cloud providers", + persistent_hint=True, + error=("!api_token", False), + ) + with vuetify.VTabsWindowItem(): + vuetify.VTextField( + label="Base URL", + v_model=("local_base_url", "http://localhost:11434/v1"), + placeholder="http://localhost:11434/v1", + density="compact", + variant="outlined", + hint="Ollama, LM Studio, and other OpenAI-compatible servers", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Model name", + v_model=("local_model", "devstral"), + placeholder="devstral", + density="compact", + variant="outlined", + hint="Model identifier as served by your endpoint", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="API token", + v_model=("api_token", "ollama"), + placeholder="ollama", + type="password", + density="compact", + variant="outlined", + hint="Optional for local servers", + persistent_hint=True, + ) + + +def _advanced_tab() -> None: + with vuetify.VTabsWindowItem(value="advanced"): + with vuetify.VCardText(classes="pa-4"): + _section("vtk-mcp") + html.Div( + "Connect a vtk-mcp server to ground generation in the real VTK API " + "and validate code before returning it.", + classes=_DESC, + ) + vuetify.VTextField( + label="Server URL", + v_model=("mcp_url", ""), + placeholder="http://localhost:8000", + clearable=True, + density="compact", + variant="outlined", + hint="Leave blank for baseline generation without tools", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Top K", + v_model=("top_k", 5), + type="number", + min=1, + max=15, + density="compact", + variant="outlined", + disabled=("!mcp_url",), + hint="Context snippets retrieved per request", + persistent_hint=True, + ) + + vuetify.VDivider(classes="my-5") + _section("Generation") + with html.Div(classes="d-flex align-center justify-space-between mt-2 mb-1"): + html.Span("Temperature", classes="text-body-2") + html.Span( + "{{ temperature }}", classes="text-body-2 text-medium-emphasis" + ) + vuetify.VSlider( + v_model=("temperature", 0.1), + min=0.0, + max=1.0, + step=0.1, + thumb_label=True, + color="primary", + hide_details=True, + disabled=("!temperature_supported",), + classes="mb-4", + ) + vuetify.VTextField( + label="Max tokens", + v_model=("max_tokens", 1000), + type="number", + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VTextField( + label="Retry attempts", + v_model=("retry_attempts", 3), + type="number", + min=1, + max=5, + density="compact", + variant="outlined", + ) From e43b360ebd408582622a4149890c9908439ebc47 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 29 Jun 2026 18:49:00 -0400 Subject: [PATCH 10/11] Fix clipped first-field label in the Model tab The Cloud/Local inner tab content sat flush against the tab bar, so the first field's floating outlined label (Base URL / Provider) was cut off at the top. Add top padding inside the inner tab window so the label clears the tabs. --- src/vtk_prompt/ui/layout/settings_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index 004fba0..a51bc21 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -113,7 +113,7 @@ def _model_tab() -> None: ): vuetify.VTab("Cloud", prepend_icon="mdi-cloud-outline") vuetify.VTab("Local", prepend_icon="mdi-laptop") - with vuetify.VTabsWindow(v_model="tab_index"): + with vuetify.VTabsWindow(v_model="tab_index", classes="pt-3"): with vuetify.VTabsWindowItem(): vuetify.VSelect( label="Provider", From 5fdfdc623137356f0f8ad4d266997647cb0b1c03 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 17 Jul 2026 09:49:09 -0400 Subject: [PATCH 11/11] Isolate conversation state per generation New conversations could inherit turns from an earlier one. The prompt client is a process-global singleton whose conversation buffer was shared by reference with app.state and mutated on a background thread, so a reset or session switch during an in-flight generation could let the stale result write back over the fresh conversation, and direct reads of the singleton could surface old turns. Make app.state.conversation the single source of truth: the client is handed a copy each generation, the async write-back is tagged with a conversation epoch that a reset or session switch bumps (a superseded generation is discarded), and the remaining direct reads of the client buffer now read app.state instead. --- src/vtk_prompt/controllers/conversation.py | 7 ++----- src/vtk_prompt/controllers/generation.py | 6 ++++++ src/vtk_prompt/controllers/sessions.py | 6 ++++-- src/vtk_prompt/state/initializer.py | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index d2f9b33..153820b 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -200,9 +200,7 @@ def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: def save_conversation(app: Any) -> str: """Save current conversation history as JSON string.""" - if hasattr(app, "prompt_client") and app.prompt_client is not None: - return json.dumps(app.prompt_client.conversation, indent=2) - return "" + return json.dumps(getattr(app.state, "conversation", None) or [], indent=2) def _parse_assistant_content(content: str) -> tuple[str | None, str | None]: @@ -350,8 +348,7 @@ def _update_navigation_state(app: Any) -> None: def sync_with_prompt_client(app: Any) -> None: """Sync conversation navigation with prompt client conversation.""" - if app.prompt_client and app.prompt_client.conversation: - app.state.conversation = app.prompt_client.conversation + if app.state.conversation: build_conversation_navigation(app) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 835afba..576f0ed 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -82,6 +82,10 @@ async def generate_and_execute_code(app: Any) -> None: app._init_prompt_client() if hasattr(app.state, "error_message") and app.state.error_message: return + # Tie this generation to the active conversation. A reset or session + # switch during the offloaded query bumps the epoch, so a stale result + # is discarded rather than written back over the new conversation. + epoch = getattr(app, "_conversation_epoch", 0) # Refine the CURRENT editor code (including manual edits), not the # model's previous output, so generation mutates what is on screen. @@ -103,6 +107,8 @@ async def generate_and_execute_code(app: Any) -> None: custom_prompt=app.custom_prompt_data, ui_mode=True, # This tells the client to use UI-specific components ) + if getattr(app, "_conversation_epoch", 0) != epoch: + return # conversation was reset/switched while the query ran # Keep UI in sync with conversation app.state.conversation = app.prompt_client.conversation diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index 07929a6..5c44985 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -91,8 +91,7 @@ def _maybe_title(app: Any, sess: dict) -> None: def capture_current_session(app: Any) -> None: """Snapshot the live app state into the current session object.""" sess = current_session(app) - client = getattr(app, "prompt_client", None) - messages = list(getattr(client, "conversation", None) or app.state.conversation or []) + messages = list(app.state.conversation or []) sess["messages"] = messages sess["code_history"] = list(app.state.code_history or []) sess["code_history_labels"] = list(app.state.code_history_labels or []) @@ -126,6 +125,8 @@ def _key(s: dict): def _reset_live(app: Any) -> None: """Clear all live conversation/code state (the fresh-conversation hinge).""" + # Invalidate any in-flight generation so its result is not written back here. + app._conversation_epoch = getattr(app, "_conversation_epoch", 0) + 1 client = getattr(app, "prompt_client", None) if client: client.conversation = [] @@ -155,6 +156,7 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None: return sess = sessions[session_id] app.state.current_session_id = session_id + app._conversation_epoch = getattr(app, "_conversation_epoch", 0) + 1 client = getattr(app, "prompt_client", None) if client: diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 8c7b2f2..4f8c5ae 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -161,7 +161,7 @@ def init_prompt_client(app: Any) -> None: mcp_url = getattr(app.state, "mcp_url", "").strip() or None app.prompt_client = VTKPromptClient( verbose=False, - conversation=app.state.conversation, + conversation=list(app.state.conversation or []), mcp_url=mcp_url, ) except ValueError as e: