diff --git a/src/vtk_prompt/cli.py b/src/vtk_prompt/cli.py
index 8de1286..7895d13 100644
--- a/src/vtk_prompt/cli.py
+++ b/src/vtk_prompt/cli.py
@@ -13,7 +13,7 @@
import click
from . import get_logger
-from .client import VTKPromptClient
+from .client import VTKPromptClient, load_conversation, save_conversation
from .provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER, get_default_model, supports_temperature
logger = get_logger(__name__)
@@ -138,13 +138,12 @@ def main(
temperature = 1.0
try:
- client = VTKPromptClient(
- verbose=verbose,
- conversation_file=conversation,
- mcp_url=mcp_url,
- )
+ client = VTKPromptClient(verbose=verbose, mcp_url=mcp_url)
+ # The caller owns the conversation: load it, hand it to query, save it back.
+ messages = load_conversation(conversation)
result = client.query(
input_string,
+ conversation=messages,
api_key=token,
model=model,
base_url=base_url,
@@ -155,6 +154,7 @@ def main(
provider=provider,
custom_prompt=custom_prompt_data,
)
+ save_conversation(conversation, messages)
# Handle result with optional validation warnings
if isinstance(result, tuple):
diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py
index 3177d6c..562a37a 100644
--- a/src/vtk_prompt/client.py
+++ b/src/vtk_prompt/client.py
@@ -33,73 +33,79 @@
logger = get_logger(__name__)
-@dataclass
-class VTKPromptClient:
- """OpenAI client for VTK code generation."""
-
- _instance: "VTKPromptClient" | None = None
- _initialized: bool = False
- verbose: bool = False
- conversation_file: str | None = None
- conversation: list[dict[str, str]] | None = None
- mcp_url: str | None = None
-
- def __new__(cls, **kwargs: Any) -> "VTKPromptClient":
- """Create singleton instance of VTKPromptClient."""
- # Make sure that this is a singleton
- if cls._instance is None:
- cls._instance = super(VTKPromptClient, cls).__new__(cls)
- cls._instance._initialized = False
- cls._instance.conversation = []
- return cls._instance
-
- def __post_init__(self) -> None:
- """Post-init hook to prevent double initialization in singleton."""
- if hasattr(self, "_initialized") and self._initialized:
- return
- self._initialized = True
-
- def load_conversation(self) -> list[dict[str, str]]:
- """Load conversation history from file."""
- if not self.conversation_file or not Path(self.conversation_file).exists():
- return []
-
+def _parse_text_tool_calls(content: str | None, tool_names: set[str]) -> list[dict] | None:
+ """Extract tool calls a backend emitted as text instead of structured tool_calls.
+
+ Some local OpenAI-compatible backends (e.g. Ollama with a quantized model) return
+ a tool call as plain content rather than populating ``tool_calls``. Handle both a
+ ``{...}`` block and a bare JSON object with ``name`` and
+ ``arguments``. Only objects whose name matches a known tool are treated as calls,
+ so normal tagged answers are never misread. Returns a list of {name, arguments}.
+ """
+ if not content:
+ return None
+ candidates = re.findall(r"\s*(\{.*?\})\s*", content, re.DOTALL)
+ if not candidates:
+ stripped = content.strip()
+ if stripped.startswith("{") and stripped.endswith("}"):
+ candidates = [stripped]
+ calls: list[dict] = []
+ for raw in candidates:
try:
- with open(self.conversation_file, "r") as f:
- data = json.load(f)
- if isinstance(data, list):
- return data
- else:
- logger.warning("Invalid conversation file format, no history loaded.")
- return []
- except Exception as e:
- logger.error("Could not load conversation file: %s", e)
+ obj = json.loads(raw)
+ except (ValueError, TypeError):
+ continue
+ name = obj.get("name")
+ args = obj.get("arguments", {})
+ if isinstance(args, str):
+ try:
+ args = json.loads(args)
+ except (ValueError, TypeError):
+ args = {}
+ if name in tool_names and isinstance(args, dict):
+ calls.append({"name": name, "arguments": args})
+ return calls or None
+
+
+def load_conversation(path: str | None) -> list[dict[str, str]]:
+ """Load conversation history from a file path."""
+ if not path or not Path(path).exists():
+ return []
+ try:
+ with open(path, "r") as f:
+ data = json.load(f)
+ if isinstance(data, list):
+ return data
+ logger.warning("Invalid conversation file format, no history loaded.")
return []
+ except Exception as e:
+ logger.error("Could not load conversation file: %s", e)
+ return []
- def save_conversation(self) -> None:
- """Save conversation history to file."""
- if not self.conversation_file or not self.conversation:
- return
- try:
- # Ensure directory exists
- Path(self.conversation_file).parent.mkdir(parents=True, exist_ok=True)
+def save_conversation(path: str | None, messages: list[dict[str, str]]) -> None:
+ """Save conversation history to a file path."""
+ if not path or not messages:
+ return
+ try:
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w") as f:
+ json.dump(messages, f, indent=2)
+ except Exception as e:
+ logger.error("Could not save conversation file: %s", e)
- with open(self.conversation_file, "w") as f:
- json.dump(self.conversation, f, indent=2)
- except Exception as e:
- logger.error("Could not save conversation file: %s", e)
- def update_conversation(
- self, new_convo: list[dict[str, str]], new_convo_file: str | None = None
- ) -> None:
- """Update conversation history with new conversation."""
- if not self.conversation:
- self.conversation = []
- self.conversation.extend(new_convo)
+@dataclass
+class VTKPromptClient:
+ """Stateless OpenAI client for VTK code generation.
- if new_convo_file:
- self.conversation_file = new_convo_file
+ The client holds only transport configuration. Conversation state belongs to
+ the caller (a UI session or the CLI), which passes its message list into
+ query() and owns it afterwards.
+ """
+
+ verbose: bool = False
+ mcp_url: str | None = None
def validate_code_syntax(self, code_string: str) -> tuple[bool, str | None]:
"""Validate Python code syntax using AST."""
@@ -296,6 +302,9 @@ def query(
custom_prompt: dict | None = None,
ui_mode: bool = False,
execution_error: str | None = None,
+ log_tool_calls: bool = False,
+ agentic_retrieval: bool = False,
+ conversation: list[dict[str, str]] | None = None,
) -> tuple[str, str, Any] | tuple[str, str, Any, list[str]] | str:
"""Generate VTK code using vtk-mcp tools when available.
@@ -321,11 +330,11 @@ def query(
# Create client with current parameters
client = openai.OpenAI(api_key=api_key, base_url=base_url)
- # Load existing conversation if present
- if self.conversation_file and not self.conversation:
- self.conversation = self.load_conversation()
+ # The caller owns the conversation; append to it in place so the result is
+ # visible to whoever passed it in. No conversation state lives on self.
+ messages: list[dict[str, str]] = conversation if conversation is not None else []
- if not message and not self.conversation:
+ if not message and not messages:
raise ValueError("No prompt or conversation file provided")
# Set up vtk-mcp client (context retrieval, tool calling, and code validation)
@@ -343,9 +352,9 @@ def query(
if execution_error:
# Retry after execution failure: append error and let LLM fix it with tools
- if not self.conversation:
+ if not messages:
raise ValueError("No conversation to retry")
- self.conversation.append(
+ messages.append(
{
"role": "user",
"content": (
@@ -359,7 +368,8 @@ def query(
else:
# Normal path: build context and prompt
context_snippets = None
- if mcp_client:
+ # Agentic mode: skip pre-injected context so the model must use tools.
+ if mcp_client and not agentic_retrieval:
mcp_context = mcp_client.get_enriched_context(message, top_k=top_k)
if mcp_context:
context_snippets = mcp_context
@@ -410,13 +420,18 @@ def query(
mcp_str = " + MCP" if mcp_client else ""
logger.debug(f"Using component assembly ({mode_str}{mcp_str})")
- if not self.conversation:
- self.conversation = list(yaml_messages)
+ if not messages:
+ messages.extend(yaml_messages)
elif message and yaml_messages:
- self.conversation.append(yaml_messages[-1])
+ messages.append(yaml_messages[-1])
# Fetch vtk-mcp tools for LLM tool calling
tools = mcp_client.list_tools() if mcp_client else []
+ tool_names: set[str] = {
+ str(name)
+ for t in tools
+ if (name := (t.get("function") or {}).get("name")) is not None
+ }
# Retry loop for AST validation
for attempt in range(retry_attempts):
@@ -432,7 +447,7 @@ def query(
for _ in range(MAX_TOOL_ROUNDS):
response = client.chat.completions.create(
model=model,
- messages=self.conversation, # type: ignore[arg-type]
+ messages=messages, # type: ignore[arg-type]
max_completion_tokens=max_tokens,
temperature=temperature,
**( # type: ignore[call-overload]
@@ -459,7 +474,7 @@ def query(
}
for tc in choice.message.tool_calls
]
- self.conversation.append(tc_msg)
+ messages.append(tc_msg)
# Execute each tool and append results
for tc in choice.message.tool_calls:
@@ -468,18 +483,64 @@ def query(
except Exception:
args = {}
result = mcp_client.call_tool(tc.function.name, args) # type: ignore
- logger.debug("Tool %s -> %s...", tc.function.name, result[:80])
- self.conversation.append(
+ if log_tool_calls:
+ logger.info(
+ "vtk-mcp: %s(%s) -> %s",
+ tc.function.name,
+ tc.function.arguments,
+ result[:120],
+ )
+ else:
+ logger.debug("Tool %s -> %s...", tc.function.name, result[:80])
+ messages.append(
{"role": "tool", "tool_call_id": tc.id, "content": result}
)
continue # let LLM decide what to do next
+ # Fallback: the backend returned a tool call as plain text rather than
+ # in tool_calls (common with quantized local models). Run it anyway.
+ text_calls = _parse_text_tool_calls(choice.message.content, tool_names)
+ if tools and text_calls:
+ text_msg: dict = {"role": "assistant", "content": ""}
+ text_msg["tool_calls"] = [
+ {
+ "id": f"call_{i}",
+ "type": "function",
+ "function": {
+ "name": c["name"],
+ "arguments": json.dumps(c["arguments"]),
+ },
+ }
+ for i, c in enumerate(text_calls)
+ ]
+ messages.append(text_msg)
+ for i, c in enumerate(text_calls):
+ result = mcp_client.call_tool(c["name"], c["arguments"]) # type: ignore
+ if log_tool_calls:
+ logger.info(
+ "vtk-mcp (text): %s(%s) -> %s",
+ c["name"],
+ json.dumps(c["arguments"]),
+ result[:120],
+ )
+ else:
+ logger.debug("Tool %s (text) -> %s...", c["name"], result[:80])
+ messages.append(
+ {"role": "tool", "tool_call_id": f"call_{i}", "content": result}
+ )
+ continue
+
# LLM generated a response (not a tool call)
content = choice.message.content or "No content in response"
break
if content is None:
# Tool loop exhausted without a text response
+ if log_tool_calls:
+ logger.info(
+ "vtk-mcp: tool loop hit the %d-round cap without a final response",
+ MAX_TOOL_ROUNDS,
+ )
if attempt == retry_attempts - 1:
return ("No response generated", "", getattr(response, "usage", None) or {})
continue
@@ -500,8 +561,8 @@ def query(
if not expl_matches or not code_matches:
if attempt < retry_attempts - 1:
- self.conversation.append({"role": "assistant", "content": content})
- self.conversation.append(
+ messages.append({"role": "assistant", "content": content})
+ messages.append(
{
"role": "user",
"content": (
@@ -526,8 +587,8 @@ def query(
if vtk_diagnostics:
if attempt < retry_attempts - 1:
logger.debug("vtk-mcp validation issues found, retrying")
- self.conversation.append({"role": "assistant", "content": content})
- self.conversation.append(
+ messages.append({"role": "assistant", "content": content})
+ messages.append(
{
"role": "user",
"content": (
@@ -543,8 +604,7 @@ def query(
f"VTK API issues found:\n{vtk_diagnostics}"
)
if message:
- self.conversation.append({"role": "assistant", "content": content})
- self.save_conversation()
+ messages.append({"role": "assistant", "content": content})
if validation_warnings:
return (
generated_explanation,
@@ -557,8 +617,8 @@ def query(
elif attempt < retry_attempts - 1:
if self.verbose:
logger.warning("AST validation failed: %s. Retrying...", error_msg)
- self.conversation.append({"role": "assistant", "content": content})
- self.conversation.append(
+ messages.append({"role": "assistant", "content": content})
+ messages.append(
{
"role": "user",
"content": (
@@ -571,8 +631,7 @@ def query(
if self.verbose:
logger.error("Final attempt failed AST validation: %s", error_msg)
if message:
- self.conversation.append({"role": "assistant", "content": content})
- self.save_conversation()
+ messages.append({"role": "assistant", "content": content})
if validation_warnings:
return (
generated_explanation,
diff --git a/src/vtk_prompt/completion.py b/src/vtk_prompt/completion.py
index 48daedf..a21aeba 100644
--- a/src/vtk_prompt/completion.py
+++ b/src/vtk_prompt/completion.py
@@ -47,6 +47,29 @@ def register_runtime_objects(**objects) -> None:
_NS.update(objects)
+def warm_up() -> None:
+ """Prime jedi's analysis of the vtk module in a background thread.
+
+ The first completion against ``vtk.`` triggers jedi to analyse the whole
+ module, which takes several seconds; subsequent calls are ~10x faster
+ because jedi caches that work. Doing it once at startup means the user's
+ first real completion is fast enough that Monaco does not time out and
+ close the popup. Never raises.
+ """
+ if not _JEDI_OK:
+ return
+
+ def _run() -> None:
+ try:
+ jedi.Interpreter("import vtk\nvtk.", [_NS]).complete(2, 4)
+ except Exception as exc: # warm-up is best-effort
+ logger.debug("jedi warm-up error: %s", exc)
+
+ import threading
+
+ threading.Thread(target=_run, name="jedi-warmup", daemon=True).start()
+
+
def complete_python(code: str, line: int, column: int, limit: int = 500) -> list[dict]:
"""Return completion candidates for ``code`` at 1-based ``line`` / 0-based ``column``.
diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py
index 601841b..a4e4433 100644
--- a/src/vtk_prompt/controllers/configuration.py
+++ b/src/vtk_prompt/controllers/configuration.py
@@ -46,6 +46,8 @@ def save_config(app: Any) -> str:
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))
+ log_tool_calls = bool(getattr(app.state, "log_tool_calls", False))
+ agentic_retrieval = bool(getattr(app.state, "agentic_retrieval", False))
base_url = getattr(app.state, "local_base_url", "").strip() if not use_cloud else ""
content = {
@@ -56,6 +58,8 @@ def save_config(app: Any) -> str:
"mcp_url": mcp_url,
"data_root": data_root,
"top_k": top_k,
+ "log_tool_calls": log_tool_calls,
+ "agentic_retrieval": agentic_retrieval,
"retries": retries,
"modelParameters": {
"temperature": temperature,
diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py
index 153820b..51a0169 100644
--- a/src/vtk_prompt/controllers/conversation.py
+++ b/src/vtk_prompt/controllers/conversation.py
@@ -46,7 +46,6 @@ def on_conversation_file_data_change(
# Extend the existing conversation with the loaded one
app.state.conversation.extend(loaded_conversation)
app.state.conversation_file = conversation_object["name"]
- app.prompt_client.update_conversation(loaded_conversation, app.state.conversation_file)
_process_loaded_conversation(app)
else:
@@ -170,9 +169,6 @@ def start_new_conversation(app: Any) -> None:
state, and the editor. The prior conversation is discarded from the UI;
preserving multiple conversations is what the sessions model would add.
"""
- if app.prompt_client:
- app.prompt_client.conversation = []
- app.prompt_client.conversation_file = None
app._conversation_checkpoints = []
app.state.conversation = []
app.state.conversation_navigation = []
@@ -460,7 +456,6 @@ def _process_multiple_conversations(app: Any, conversation_files: list[dict[str,
# Set the merged conversation
app.state.conversation = merged_conversation
app.state.conversation_file = ", ".join(valid_files)
- app.prompt_client.update_conversation(merged_conversation, app.state.conversation_file)
# Process the merged conversation
_process_loaded_conversation(app)
diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py
index 576f0ed..0b7e791 100644
--- a/src/vtk_prompt/controllers/generation.py
+++ b/src/vtk_prompt/controllers/generation.py
@@ -26,6 +26,106 @@
)
+def _unpack_result(result: Any) -> tuple[str, str]:
+ """Reduce a query result to (explanation, code) regardless of its shape."""
+ if isinstance(result, tuple):
+ if len(result) >= 2:
+ return str(result[0]), str(result[1])
+ return str(result[0]) if result else "", ""
+ return str(result), ""
+
+
+def _deliver_to_background_session(
+ app: Any, session_id: str, messages: list, result: Any
+) -> None:
+ """Store a finished generation in a conversation the user is not viewing.
+
+ The visible conversation keeps the render window; this only updates the
+ originating session record and flags it so the drawer can show that it has a
+ new result waiting.
+ """
+ from . import sessions as sessions_mod
+
+ sess = sessions_mod.sessions_by_id(app).get(session_id)
+ if sess is None:
+ return # conversation was deleted while the query ran
+ explanation, code = _unpack_result(result)
+ display_code = EXPLAIN_RENDERER + "\n" + code if code else ""
+
+ sess["messages"] = list(messages)
+ if display_code:
+ history = list(sess.get("code_history") or [])
+ labels = list(sess.get("code_history_labels") or [])
+ if not history or history[-1] != display_code:
+ history.append(display_code)
+ labels.append(sess.get("pending_prompt") or "Generated")
+ sess["code_history"] = history
+ sess["code_history_labels"] = labels
+ sess["code_history_pos"] = len(history) - 1
+ sess["explanation"] = explanation
+ sess.pop("error_message", None)
+ sess["unseen"] = True
+ sess.pop("pending_prompt", None)
+ sessions_mod.finish_background_session(app, sess)
+
+
+def _deliver_error(app: Any, session_id: str, message: str) -> None:
+ """Attach a failure to the conversation that caused it.
+
+ An error belongs to its conversation, so a background failure is stored on
+ that session and surfaces when the user switches to it instead of
+ interrupting whatever they are looking at now.
+ """
+ if _is_visible(app, session_id):
+ # The console is the single record now; no floating alert.
+ console_message(app, message)
+ return
+ from . import sessions as sessions_mod
+
+ sess = sessions_mod.sessions_by_id(app).get(session_id)
+ if sess is None:
+ return
+ sess["error_message"] = message
+ sess["unseen"] = True
+ sess.pop("pending_prompt", None)
+ sessions_mod.finish_background_session(app, sess)
+
+
+def _generating_sessions(app: Any) -> set:
+ """Ids of conversations with a generation in flight (one per conversation)."""
+ if not hasattr(app, "_generating_session_ids"):
+ app._generating_session_ids = set()
+ return app._generating_session_ids
+
+
+def conversation_token(app: Any, session_id: str) -> int:
+ """Return the current generation token for one conversation.
+
+ A generation captures this at start and its result is delivered only if the
+ token still matches. Bumped when the conversation is reset or a new
+ generation starts in it, so an obsolete result is dropped. Crucially, merely
+ switching between conversations does NOT bump anyone's token, so returning to
+ a conversation does not orphan its own in-flight generation.
+ """
+ tokens = getattr(app, "_conversation_tokens", None)
+ if tokens is None:
+ tokens = app._conversation_tokens = {}
+ return tokens.get(session_id, 0)
+
+
+def bump_conversation_token(app: Any, session_id: str) -> None:
+ """Invalidate any in-flight generation belonging to one conversation."""
+ tokens = getattr(app, "_conversation_tokens", None)
+ if tokens is None:
+ tokens = app._conversation_tokens = {}
+ tokens[session_id] = tokens.get(session_id, 0) + 1
+
+
+def _is_visible(app: Any, session_id: str) -> bool:
+ """Whether the given conversation is the one currently on screen."""
+ return (getattr(app.state, "current_session_id", "") or "") == session_id
+
+
def generate_code(app: Any) -> None:
"""Generate VTK code from user query.
@@ -34,8 +134,9 @@ def generate_code(app: Any) -> None:
synchronous re-entry guard prevents overlapping generations from a second
click (the button no longer freezes the UI, so double-clicks are possible).
"""
- if getattr(app, "_generating", False):
- return
+ session_id = getattr(app.state, "current_session_id", "") or ""
+ if session_id in _generating_sessions(app):
+ return # this conversation is already generating; others may proceed
# Mirror the send button's disabled condition so Ctrl+Enter (which bypasses
# the button) cannot submit an empty prompt or run without a cloud token.
if not (getattr(app.state, "query_text", "") or "").strip():
@@ -44,11 +145,15 @@ def generate_code(app: Any) -> None:
getattr(app.state, "api_token", "") or ""
).strip():
return
- app._generating = True
- asynchronous.create_task(generate_and_execute_code(app))
+ bump_conversation_token(app, session_id)
+ _generating_sessions(app).add(session_id)
+ from . import sessions as sessions_mod
+
+ sessions_mod.refresh_sessions_list(app) # show the spinner on this conversation
+ asynchronous.create_task(generate_and_execute_code(app, session_id))
-async def generate_and_execute_code(app: Any) -> None:
+async def generate_and_execute_code(app: Any, origin_session_id: str = "") -> None:
"""Generate VTK code using AI API and execute it.
Only the blocking network call (prompt_client.query) is offloaded to a
@@ -56,8 +161,9 @@ async def generate_and_execute_code(app: Any) -> None:
the event loop (main) thread, so all VTK execution and rendering stays
main-thread-bound as VTK/OpenGL requires.
"""
- app.state.is_loading = True
- app.state.error_message = ""
+ if _is_visible(app, origin_session_id):
+ app.state.is_loading = True
+ app.state.error_message = ""
app.state.flush() # show the spinner immediately, before the slow request
try:
@@ -76,16 +182,26 @@ async def generate_and_execute_code(app: Any) -> None:
# the sent text does not linger (Claude-style).
app.state.current_prompt = enhanced_query
app.state.query_text = ""
+ # Record the prompt in the conversation immediately, so switching away
+ # mid-generation still snapshots what this conversation is about.
+ if enhanced_query:
+ app.state.conversation = list(app.state.conversation or []) + [
+ {"role": "user", "content": enhanced_query}
+ ]
app.state.flush()
# Reinitialize client with current settings
app._init_prompt_client()
- if hasattr(app.state, "error_message") and app.state.error_message:
+ if getattr(app.state, "error_message", ""):
+ # Config/validation error (e.g. missing API key). Surface it in
+ # the console like every other error, then clear the signal.
+ console_message(app, app.state.error_message)
+ 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)
+ # Tie this generation to its conversation by token. The result is
+ # delivered only if this conversation has not been reset or
+ # re-generated meanwhile. Switching conversations does not change it.
+ token = conversation_token(app, origin_session_id)
# Refine the CURRENT editor code (including manual edits), not the
# model's previous output, so generation mutates what is on screen.
@@ -93,9 +209,13 @@ async def generate_and_execute_code(app: Any) -> None:
sync_editor_code_into_conversation(app)
+ # This generation works on its own copy; it is adopted as the
+ # conversation only if this is still the active one when it finishes.
+ messages = list(app.state.conversation or [])
result = await asyncio.to_thread(
app.prompt_client.query,
enhanced_query,
+ conversation=messages,
api_key=app._get_api_key(),
model=app._get_model(),
base_url=app._get_base_url(),
@@ -103,14 +223,22 @@ async def generate_and_execute_code(app: Any) -> None:
temperature=float(app.state.temperature),
top_k=int(app.state.top_k),
retry_attempts=int(app.state.retry_attempts),
+ log_tool_calls=bool(app.state.log_tool_calls),
+ agentic_retrieval=bool(app.state.agentic_retrieval),
provider=app.state.provider,
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
+ if conversation_token(app, origin_session_id) != token:
+ # The originating conversation was reset or re-generated; drop this.
+ return
+ if not _is_visible(app, origin_session_id):
+ # The user moved on. Deliver into the originating conversation
+ # without touching the visible one or the render window.
+ _deliver_to_background_session(app, origin_session_id, messages, result)
+ return
# Keep UI in sync with conversation
- app.state.conversation = app.prompt_client.conversation
+ app.state.conversation = messages
# Handle result with optional validation warnings
validation_warnings: list[str] = []
@@ -166,8 +294,10 @@ async def generate_and_execute_code(app: Any) -> None:
if not success and exec_error and getattr(app.state, "mcp_url", "").strip():
logger.debug("Execution error, retrying with vtk-mcp: %s", exec_error)
app.state.error_message = ""
+ retry_messages = list(app.state.conversation or [])
retry_result = await asyncio.to_thread(
app.prompt_client.query,
+ conversation=retry_messages,
execution_error=exec_error,
api_key=app._get_api_key(),
model=app._get_model(),
@@ -176,11 +306,13 @@ async def generate_and_execute_code(app: Any) -> None:
temperature=float(app.state.temperature),
top_k=int(app.state.top_k),
retry_attempts=1,
+ log_tool_calls=bool(app.state.log_tool_calls),
+ agentic_retrieval=bool(app.state.agentic_retrieval),
provider=app.state.provider,
custom_prompt=app.custom_prompt_data,
ui_mode=True,
)
- app.state.conversation = app.prompt_client.conversation
+ app.state.conversation = retry_messages
if isinstance(retry_result, tuple) and len(retry_result) >= 2:
_, retry_code = retry_result[0], retry_result[1]
if retry_code:
@@ -198,16 +330,22 @@ async def generate_and_execute_code(app: Any) -> None:
execute_with_renderer(app, app.state.generated_code)
except ValueError as e:
if "max_tokens" in str(e):
- app.state.error_message = (
+ msg = (
f"{str(e)} Current: {app.state.max_tokens}. Try increasing max tokens."
)
else:
- app.state.error_message = f"Error generating code: {str(e)}"
+ msg = f"Error generating code: {str(e)}"
+ _deliver_error(app, origin_session_id, msg)
except Exception as e:
- app.state.error_message = f"Error generating code: {str(e)}"
+ _deliver_error(app, origin_session_id, f"Error generating code: {str(e)}")
finally:
- app.state.is_loading = False
- app._generating = False
+ _generating_sessions(app).discard(origin_session_id)
+ from . import sessions as sessions_mod
+
+ sessions_mod.refresh_sessions_list(app) # clear this conversation's spinner
+ # Only the visible conversation owns the in-pane spinner.
+ if _is_visible(app, origin_session_id):
+ app.state.is_loading = False
app.state.flush() # push final state (result/error, spinner off) to client
@@ -231,6 +369,120 @@ def _format_exec_error(displayed_code: str, error_message: str, line_text: str |
return f"{where}\n{error_message}"
+def _classify_line(text: str) -> str:
+ """Tag a captured line by severity so the console can colour it.
+
+ VTK (C++) and Python both use recognisable markers: "ERROR"/"Traceback" for
+ failures, "Warning" for warnings. Everything else is ordinary output.
+ """
+ low = text.lower()
+ if ("error" in low) or ("traceback" in low) or low.startswith(" file "):
+ return "err"
+ if ("warning" in low) or ("warn:" in low) or ("deprecat" in low):
+ return "warn"
+ return "out"
+
+
+def console_message(app: Any, text: str, level: str = "err") -> None:
+ """Record a standalone message (not tied to code output) in the console.
+
+ Used for generation and configuration errors that occur before any run, so
+ the console remains the single place all errors and output appear.
+ """
+ if not text:
+ return
+ if level == "err":
+ _append_console(app, stdout="", stderr="", error=text)
+ else:
+ _append_console(app, stdout="", stderr="", extra_warnings=[text])
+
+
+def _append_console(
+ app: Any,
+ stdout: str,
+ stderr: str = "",
+ error: str | None = None,
+ extra_warnings: list[str] | None = None,
+) -> None:
+ """Record a run's captured output as a collapsible group in the console.
+
+ Each run is one entry {stamp, lines:[{kind,text}], summary}. A run that
+ produced no output is skipped, so the console never shows an empty marker.
+ """
+ import time
+
+ def _cap(s: str) -> str:
+ # A single very long line (e.g. print(dir(vtk))) is unwieldy; keep the
+ # console readable by truncating with an indicator.
+ return s if len(s) <= 2000 else s[:2000] + " ... [truncated]"
+
+ entries: list[dict] = []
+ # stdout is ordinary output; stderr is a warning; a raised exception is an
+ # error. Classify by origin rather than by scanning text for words like
+ # "error" (a printed class name such as vtkErrorCode is not an error).
+ for raw in (stdout or "").splitlines():
+ entries.append({"kind": "out", "text": _cap(raw)})
+ for raw in (stderr or "").splitlines():
+ # Everything on stderr is at least a warning; VTK also writes hard
+ # errors there, so upgrade to error when the text says so.
+ kind = "err" if _classify_line(raw) == "err" else "warn"
+ entries.append({"kind": kind, "text": _cap(raw)})
+ if error:
+ for raw in error.splitlines():
+ entries.append({"kind": "err", "text": _cap(raw)})
+ for warning in extra_warnings or []:
+ for raw in warning.splitlines():
+ entries.append({"kind": "warn", "text": _cap(raw)})
+ if not entries:
+ return # nothing to show for this run
+
+ n_err = sum(1 for e in entries if e["kind"] == "err")
+ n_warn = sum(1 for e in entries if e["kind"] == "warn")
+ parts = [f"{len(entries)} line" + ("s" if len(entries) != 1 else "")]
+ if n_err:
+ parts.append(f"{n_err} error" + ("s" if n_err != 1 else ""))
+ if n_warn:
+ parts.append(f"{n_warn} warning" + ("s" if n_warn != 1 else ""))
+ stamp = time.strftime("%H:%M:%S")
+ level = "err" if n_err else ("warn" if n_warn else "out")
+ runs = list(getattr(app.state, "console_log", []) or [])
+ runs.append(
+ {"stamp": stamp, "lines": entries, "summary": ", ".join(parts), "level": level}
+ )
+ app.state.console_log = runs[-100:]
+ # Severity of the latest run, for the Console tab badge.
+ app.state.console_level = level
+
+ # Flat, render-friendly view: a header line per run then its output lines.
+ # A single non-nested list keeps the UI markup simple and robust.
+ _class = {
+ "err": "text-error",
+ "warn": "text-warning",
+ "run": "text-medium-emphasis font-weight-medium",
+ "out": "text-high-emphasis",
+ }
+ flat = list(getattr(app.state, "console_lines", []) or [])
+ header = {"kind": "run", "text": f"\u25b6 {stamp} \u2014 {', '.join(parts)}"}
+ for item in [header, *entries]:
+ item["cls"] = _class.get(item["kind"], "text-high-emphasis")
+ flat.append(item)
+ app.state.console_lines = flat[-1000:]
+
+
+def apply_data_suggestion(app: Any, missing: str, suggestion: str) -> None:
+ """Replace an unresolved data-file reference with a chosen known file and re-run."""
+ history = app.state.code_history or []
+ pos = app.state.code_history_pos
+ code = history[pos] if 0 <= pos < len(history) else (app.state.generated_code or "")
+ for quote in ("'", '"'):
+ code = code.replace(f"{quote}{missing}{quote}", f"{quote}{suggestion}{quote}")
+ app.state.generated_code = code
+ push_code_snapshot(app, code, f"use {suggestion}")
+ app.state.data_suggestions = []
+ app.state.error_message = ""
+ execute_with_renderer(app, code)
+
+
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
@@ -243,14 +495,46 @@ def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None]
exec_code, app.renderer, app.render_window
)
+ # The formatted run error goes to the console (below), not a floating alert.
if not success and error_message:
- app.state.error_message = _format_exec_error(
+ error_message = _format_exec_error(
code_string, error_message, error_line_text
)
+ # Offer one-click fixes for data references that could not be resolved
+ # (e.g. can.ex -> can.ex2). Checked regardless of Python-level success,
+ # since some VTK readers log an error and return without raising.
+ from ..data.resolver import suggestions
+
+ picks: list[dict] = []
+ for hint in suggestions(code_string):
+ for match in hint["matches"]:
+ picks.append({"missing": hint["name"], "suggestion": match})
+ app.state.data_suggestions = picks
+ resolver_warning = ""
+ if picks:
+ names = ", ".join(sorted({p["missing"] for p in picks}))
+ resolver_warning = (
+ f"Could not resolve data file(s): {names}. "
+ "Use the Fix data file menu to pick a close match."
+ )
+
if success:
app.state.rendered_code = code_string
+ # The console is the single record of a run: stdout, stderr, any exception,
+ # and the resolver's suggestion. No floating alert.
+ from ..rendering.code_executor import last_console_output
+
+ _stdout, _stderr = last_console_output()
+ _append_console(
+ app,
+ _stdout,
+ _stderr,
+ error_message if not success else None,
+ extra_warnings=[resolver_warning] if resolver_warning else None,
+ )
+
# Always update view
try:
app.ctrl.view_update()
diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py
index 5c44985..c813b01 100644
--- a/src/vtk_prompt/controllers/sessions.py
+++ b/src/vtk_prompt/controllers/sessions.py
@@ -23,6 +23,7 @@
_PERSIST_KEYS = (
"id", "title", "created", "updated", "pinned", "messages",
"code_history", "code_history_labels", "code_history_pos", "checkpoints",
+ "console_log", "console_lines", "console_level",
)
@@ -46,9 +47,32 @@ def _new_session() -> dict:
"code_history_labels": [],
"code_history_pos": -1,
"checkpoints": [],
+ "console_log": [],
+ "console_lines": [],
+ "console_level": "out",
}
+def sessions_by_id(app: Any) -> dict:
+ """All known sessions keyed by id (public accessor for other controllers)."""
+ return _sessions(app)
+
+
+def clear_session_error(app: Any, session_id: str) -> None:
+ """Drop a conversation's stored error (its next generation supersedes it)."""
+ sess = _sessions(app).get(session_id)
+ if sess is not None:
+ sess.pop("error_message", None)
+
+
+def finish_background_session(app: Any, sess: dict) -> None:
+ """Persist a conversation that finished while the user was looking elsewhere."""
+ sess["updated"] = time.time()
+ _maybe_title(app, sess)
+ _persist_session(sess)
+ refresh_sessions_list(app)
+
+
def ensure_session(app: Any) -> dict:
"""Guarantee a current session exists; create the first one if needed."""
sessions = _sessions(app)
@@ -75,15 +99,28 @@ def _maybe_title(app: Any, sess: dict) -> None:
"""Set a session's title from its first user prompt (once it has one)."""
if sess["title"] not in ("", "New conversation"):
return
- nav = app.state.conversation_navigation or []
- if not nav:
- return
from .conversation import EXTRA_INSTRUCTIONS_TAG
- content = (nav[0].get("user", {}).get("content", "") or "").strip()
- if EXTRA_INSTRUCTIONS_TAG in content:
- content = content.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip()
- content = content.replace("Request:", "").strip()
+ def _clean(raw: str) -> str:
+ text = (raw or "").strip()
+ if EXTRA_INSTRUCTIONS_TAG in text:
+ text = text.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip()
+ return text.replace("Request:", "").strip()
+
+ # Title from the session's own first prompt. Live navigation describes the
+ # conversation on screen, which is the wrong one when a generation finishes
+ # in a conversation the user has already navigated away from.
+ content = ""
+ for msg in sess.get("messages") or []:
+ if msg.get("role") == "user":
+ content = _clean(msg.get("content", ""))
+ if content:
+ break
+ if not content:
+ nav = app.state.conversation_navigation or []
+ if not nav:
+ return
+ content = _clean(nav[0].get("user", {}).get("content", ""))
if content:
sess["title"] = _truncate(content)
@@ -97,6 +134,9 @@ def capture_current_session(app: Any) -> None:
sess["code_history_labels"] = list(app.state.code_history_labels or [])
sess["code_history_pos"] = app.state.code_history_pos
sess["checkpoints"] = list(getattr(app, "_conversation_checkpoints", None) or [])
+ sess["console_log"] = list(app.state.console_log or [])
+ sess["console_lines"] = list(app.state.console_lines or [])
+ sess["console_level"] = getattr(app.state, "console_level", "out")
_maybe_title(app, sess)
_persist_session(sess)
@@ -112,12 +152,15 @@ def _key(s: dict):
ordered = sorted(_sessions(app).values(), key=_key)
cur = getattr(app.state, "current_session_id", "") or ""
+ busy: set[str] = getattr(app, "_generating_session_ids", set())
app.state.sessions_list = [
{
"id": s["id"],
"title": s["title"] or "New conversation",
"pinned": s["pinned"],
"active": s["id"] == cur,
+ "unseen": bool(s.get("unseen")) and s["id"] != cur,
+ "generating": s["id"] in busy,
}
for s in ordered
]
@@ -125,12 +168,10 @@ 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 = []
- client.conversation_file = None
+ # Invalidate any in-flight generation for THIS conversation only.
+ from .generation import bump_conversation_token
+
+ bump_conversation_token(app, getattr(app.state, "current_session_id", "") or "")
app._conversation_checkpoints = []
app.state.conversation = []
app.state.conversation_navigation = []
@@ -143,6 +184,22 @@ def _reset_live(app: Any) -> None:
app.state.code_history = []
app.state.code_history_labels = []
app.state.code_history_pos = -1
+ app.state.console_log = []
+ app.state.console_lines = []
+ app.state.console_level = "out"
+ # Busy belongs to the conversation too: a fresh one is idle even while
+ # another conversation is still generating.
+ busy: set[str] = getattr(app, "_generating_session_ids", set())
+ app.state.is_loading = (getattr(app.state, "current_session_id", "") or "") in busy
+ # The window belongs to the conversation, so every reset clears it here
+ # rather than only on the paths that remember to.
+ renderer = getattr(app, "renderer", None)
+ if renderer is not None:
+ from ..rendering import clear_scene
+
+ clear_scene(renderer, app.render_window)
+ if getattr(app.ctrl, "view_update", None):
+ app.ctrl.view_update()
def load_session(app: Any, session_id: str, execute: bool = True) -> None:
@@ -156,18 +213,36 @@ 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:
- client.conversation = list(sess["messages"])
- client.conversation_file = None
app.state.conversation = list(sess["messages"])
app.state.conversation_file = None
+ # The in-pane spinner reflects whether the conversation being shown is busy.
+ busy: set[str] = getattr(app, "_generating_session_ids", set())
+ app.state.is_loading = session_id in busy
+ app.state.error_message = ""
+ if sess.get("unseen"):
+ sess["unseen"] = False
+ refresh_sessions_list(app) # drop the new-result marker now it is seen
app.state.code_history = list(sess["code_history"])
app.state.code_history_labels = list(sess["code_history_labels"])
app.state.code_history_pos = sess["code_history_pos"]
app._conversation_checkpoints = list(sess["checkpoints"])
+ # Put this conversation's current version in the editor. Without it the
+ # editor and the render window keep showing the conversation just left.
+ history = app.state.code_history
+ pos = app.state.code_history_pos
+ app.state.generated_code = history[pos] if 0 <= pos < len(history) else ""
+ app.state.console_log = list(sess.get("console_log") or [])
+ app.state.console_lines = list(sess.get("console_lines") or [])
+ app.state.console_level = sess.get("console_level", "out")
+ # A background error belongs to this conversation: surface it in the console
+ # now (after the restore above, so it is not overwritten), then clear it.
+ _stored_error = sess.get("error_message", "") or ""
+ if _stored_error:
+ from .generation import console_message
+
+ console_message(app, _stored_error)
+ sess.pop("error_message", None)
from .conversation import (
_parse_assistant_content,
@@ -196,14 +271,23 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None:
last_user = (nav[-1].get("user", {}).get("content", "") or "").strip()
if EXTRA_INSTRUCTIONS_TAG in last_user:
last_user = last_user.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip()
- app.state.current_prompt = last_user
+ # Show the prompt as typed, without the internal "Request:" marker.
+ app.state.current_prompt = last_user.replace("Request:", "").strip()
else:
app.state.generated_explanation = ""
app.state.current_prompt = ""
app.state.query_text = ""
- if execute and app.state.generated_code:
- app._execute_with_renderer(app.state.generated_code)
+ if execute:
+ if app.state.generated_code:
+ app._execute_with_renderer(app.state.generated_code)
+ else:
+ # A conversation with no code shows an empty scene, not the last one.
+ from ..rendering import clear_scene
+
+ clear_scene(app.renderer, app.render_window)
+ if app.ctrl.view_update:
+ app.ctrl.view_update()
def switch_session(app: Any, session_id: str) -> None:
diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py
index 3685dca..328a2c7 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 difflib
import re
import shutil
import urllib.request
@@ -191,6 +192,47 @@ def referenced(code: str) -> list[str]:
return found
+# A bare literal that looks like a data file: has an extension, no path, and is
+# not obviously code or a format string.
+_DATA_LIKE_RE = re.compile(r"^[\w.\-]+\.[A-Za-z][\w]{1,5}$")
+
+
+def _looks_like_data_file(value: str) -> bool:
+ return bool(_DATA_LIKE_RE.match(value)) and "{" not in value
+
+
+def suggestions(code: str) -> list[dict]:
+ """Suggest known data files for referenced names that cannot be resolved.
+
+ The model sometimes references a file that does not exist but is close to a
+ real one (e.g. ``can.ex`` when the dataset is ``can.ex2``). For each such
+ unresolved, data-file-looking literal, return the closest known names so the
+ caller can surface them rather than silently substituting a possibly-wrong
+ dataset. Returns [{"name", "matches": [...]}] for names that have candidates.
+ """
+ if not code:
+ return []
+ known = set(_load_index()) | set(uploads.uploaded_names())
+ if not known:
+ return []
+ out: list[dict] = []
+ seen: set[str] = set()
+ for match in _LITERAL_RE.finditer(code):
+ value = match.group(2)
+ if value in seen or value in known:
+ continue
+ if ("/" in value) or ("\\" in value) or not _looks_like_data_file(value):
+ continue
+ seen.add(value)
+ stem = value.rsplit(".", 1)[0]
+ # Prefer files sharing the exact stem (can.ex -> can.ex2, can.exdg).
+ stem_matches = sorted(n for n in known if n.rsplit(".", 1)[0] == stem and n != value)
+ matches = stem_matches or difflib.get_close_matches(value, known, n=3, cutoff=0.7)
+ if matches:
+ out.append({"name": value, "matches": list(matches)[:3]})
+ return out
+
+
def artifacts(code: str) -> list[dict]:
"""Return data artifacts referenced by code (name, cache path, fetched flag).
diff --git a/src/vtk_prompt/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py
index 2be9aaa..3ac46c0 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 contextlib
+import io
import traceback
import vtk
@@ -9,6 +11,62 @@
logger = get_logger(__name__)
+# Output captured from the most recent run of generated code. The executor keeps
+# its (success, error, line) return contract; callers read the console text from
+# here so a print() in generated code is visible in the app, not just the server
+# terminal.
+_last_stdout: str = ""
+_last_stderr: str = ""
+
+
+def last_console_output() -> tuple[str, str]:
+ """(stdout, stderr) captured from the most recent execute_vtk_code call.
+
+ Kept as separate streams so callers can classify by origin: stdout is
+ ordinary output, stderr is a warning/error. This avoids guessing severity
+ from line content (e.g. a printed class name like vtkErrorCode is not an
+ error).
+ """
+ return _last_stdout, _last_stderr
+
+
+class _NoOpRenderWindow:
+ """Stand-in for a render window that generated code constructs itself.
+
+ Handing back the app's real window let scripts call AddRenderer/SetSize/
+ SetOffScreenRendering on it, which stacked renderers and corrupted the view
+ (black screen after a few switches). This absorbs those calls harmlessly; the
+ scene still reaches the app because the code draws into the injected renderer.
+ """
+
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def __getattr__(self, name: str):
+ def _noop(*args: object, **kwargs: object):
+ return None
+
+ return _noop
+
+
+class _NoOpInteractor:
+ """Stand-in for vtkRenderWindowInteractor used while running generated code.
+
+ Standalone VTK scripts end with interactor.Start(), which opens a native
+ window and blocks the event loop until the user presses q. Inside the app the
+ scene belongs to the trame view, so the interactor is replaced by an inert
+ object that accepts the usual calls and does nothing.
+ """
+
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def __getattr__(self, name: str):
+ def _noop(*args: object, **kwargs: object) -> None:
+ return None
+
+ return _noop
+
def execute_vtk_code(
code_string: str, renderer: vtk.vtkRenderer, render_window: vtk.vtkRenderWindow
@@ -41,19 +99,38 @@ def execute_vtk_code(
"__name__": "__main__",
}
- # Execute the code
- exec(code_segment, exec_globals)
-
- # Reset camera and render
+ # Keep generated code inside the app (a script that builds its own window
+ # or interactor would otherwise pop up a native window and block on
+ # Start()), and capture stdout/stderr separately so the console can
+ # colour output by stream. Restore vtk afterwards.
+ global _last_stdout, _last_stderr
+ real_window_cls = vtk.vtkRenderWindow
+ real_interactor_cls = vtk.vtkRenderWindowInteractor
+ vtk.vtkRenderWindow = _NoOpRenderWindow # type: ignore[assignment,misc]
+ vtk.vtkRenderWindowInteractor = _NoOpInteractor # type: ignore[assignment,misc]
+ out_buf, err_buf = io.StringIO(), io.StringIO()
try:
- renderer.ResetCamera()
- render_window.Render()
- except Exception as render_error:
- logger.warning("Render error: %s", render_error)
+ with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(
+ err_buf
+ ):
+ exec(code_segment, exec_globals)
+
+ # Reset camera and render
+ try:
+ renderer.ResetCamera()
+ render_window.Render()
+ except Exception as render_error:
+ logger.warning("Render error: %s", render_error)
+ finally:
+ vtk.vtkRenderWindow = real_window_cls # type: ignore[assignment,misc]
+ vtk.vtkRenderWindowInteractor = real_interactor_cls # noqa: E501 # type: ignore[assignment,misc]
+ _last_stdout, _last_stderr = out_buf.getvalue(), err_buf.getvalue()
return True, None, None
except (Exception, SystemExit) as e:
+ _last_stdout = locals().get("out_buf", io.StringIO()).getvalue()
+ _last_stderr = locals().get("err_buf", io.StringIO()).getvalue()
# 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
diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py
index 74ae8b7..56bc35a 100644
--- a/src/vtk_prompt/state/initializer.py
+++ b/src/vtk_prompt/state/initializer.py
@@ -51,7 +51,15 @@ def initialize_state(app: Any) -> None:
app.state.code_history_pos = -1
app.state.is_loading = False
app.state.mcp_url = ""
+ app.state.log_tool_calls = False # log vtk-mcp tool calls to the console
+ app.state.agentic_retrieval = False # skip pre-injected context; use tools
app.state.error_message = ""
+ app.state.console_log = [] # per-run captured output groups
+ app.state.info_tab = "conversation" # Conversation | Console tab in the info pane
+ app.state.console_open_runs = [] # which run groups are expanded
+ app.state.console_lines = [] # flat render-friendly console lines
+ app.state.console_level = "out" # severity of the latest run (tab badge colour)
+ app.state.data_suggestions = [] # one-click fixes for unresolved data files
app.state.input_tokens = 0
app.state.output_tokens = 0
app.state.advanced_settings_open = False
@@ -166,10 +174,6 @@ def init_prompt_client(app: Any) -> None:
return
mcp_url = getattr(app.state, "mcp_url", "").strip() or None
- app.prompt_client = VTKPromptClient(
- verbose=False,
- conversation=list(app.state.conversation or []),
- mcp_url=mcp_url,
- )
+ app.prompt_client = VTKPromptClient(verbose=False, mcp_url=mcp_url)
except ValueError as e:
app.state.error_message = str(e)
diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py
index 7b6c2c1..b5cee70 100644
--- a/src/vtk_prompt/ui/layout/content.py
+++ b/src/vtk_prompt/ui/layout/content.py
@@ -19,11 +19,16 @@ def build_content(layout: Any, app: Any) -> None:
"""Build the main content area with code panels and VTK viewer."""
with layout.content:
with vuetify.VContainer(
- classes="fluid fill-height", style="min-width: 100%; padding: 0!important;"
+ classes="fluid fill-height",
+ style="width: 100%; padding: 0 !important; overflow: hidden;",
):
- with vuetify.VRow(rows=12, classes="fill-height px-4 pt-1 pb-1"):
+ with vuetify.VRow(
+ rows=12,
+ classes="fill-height px-4 pt-1 pb-1 flex-nowrap",
+ style="min-width: 0;",
+ ):
# Left column - Generated code view
- with vuetify.VCol(cols=6):
+ with vuetify.VCol(cols=6, style="min-width: 0;"):
# Generated code panel (editable + re-runnable)
with vuetify.VCard(classes="h-75"):
with vuetify.VCardTitle(
@@ -122,6 +127,33 @@ def build_content(layout: Any, app: Any) -> None:
),
):
vuetify.VIcon("mdi-redo")
+ # One-click fixes for unresolved data-file references,
+ # right by the code they apply to.
+ with vuetify.VMenu():
+ with vuetify.Template(v_slot_activator="{ props }"):
+ vuetify.VBtn(
+ "Fix data file",
+ v_bind="props",
+ v_show=("data_suggestions.length > 0",),
+ prepend_icon="mdi-file-alert-outline",
+ append_icon="mdi-menu-down",
+ size="small",
+ color="warning",
+ variant="tonal",
+ classes="mr-2",
+ )
+ with vuetify.VList(density="compact"):
+ with vuetify.VListItem(
+ v_for="pick in data_suggestions",
+ key="pick.missing + pick.suggestion",
+ click=(
+ app.ctrl.apply_data_suggestion,
+ "[pick.missing, pick.suggestion]",
+ ),
+ ):
+ vuetify.VListItemTitle(
+ "{{ pick.missing }} \u2192 {{ pick.suggestion }}"
+ )
# Run the (possibly edited) code without the LLM
vuetify.VBtn(
"Run",
@@ -137,6 +169,12 @@ def build_content(layout: Any, app: Any) -> None:
)
with vuetify.VCardText(style="height: calc(100% - 50px);"):
code.Editor(
+ # Remount only when the conversation changes.
+ # Keying on code version too would remount on
+ # generate/undo/redo, which disposes an open
+ # autocomplete popup mid-flight and crashes
+ # trame-code (null read in dispose).
+ key=("current_session_id",),
v_model=("generated_code", ""),
language="python",
theme="vs",
@@ -248,199 +286,258 @@ def build_content(layout: Any, app: Any) -> None:
)
# Right column - VTK viewer and prompt
- with vuetify.VCol(cols=6):
- with vuetify.VRow(no_gutters=True, classes="fill-height"):
- # Top: VTK render view
- with vuetify.VCard(classes="h-75 w-100"):
- with vuetify.VCardTitle("VTK Visualization", classes="d-flex"):
- vuetify.VSpacer()
- # Token usage display
- with vuetify.VChip(
- small=True,
- color="secondary",
- text_color="white",
- v_show="input_tokens > 0 || output_tokens > 0",
- classes="mr-2",
- density="compact",
- ):
- html.Span(
- "Tokens: In: {{ input_tokens }} | Out: {{ output_tokens }}"
- )
- # VTK control buttons
- with vuetify.VTooltip(
- text="Clear Scene",
- location="bottom",
- ):
- with vuetify.Template(v_slot_activator="{ props }"):
- with vuetify.VBtn(
- click=app.ctrl.clear_scene,
- icon=True,
- color="secondary",
- v_bind="props",
- classes="mr-2",
- density="compact",
- variant="text",
- ):
- vuetify.VIcon("mdi-reload")
- with vuetify.VTooltip(
- text="Reset Camera",
- location="bottom",
- ):
- with vuetify.Template(v_slot_activator="{ props }"):
- with vuetify.VBtn(
- click=app.ctrl.reset_camera,
- icon=True,
- color="secondary",
- v_bind="props",
- classes="mr-2",
- density="compact",
- variant="text",
- ):
- vuetify.VIcon("mdi-camera-retake-outline")
- with vuetify.VCardText(style="height: calc(100% - 50px);"):
- # VTK render window
- view = vtk_widgets.VtkRemoteView(
- app.render_window,
- ref="view",
- classes="w-100 h-100",
- interactor_settings=[
- (
- "SetInteractorStyle",
- ["vtkInteractorStyleTrackballCamera"],
- ),
- ],
+ with vuetify.VCol(
+ cols=6,
+ classes="d-flex flex-column",
+ style="min-height: 0; min-width: 0;",
+ ):
+ # Top: VTK render view. flex-grow so it owns the space and
+ # never collapses when the panel below changes.
+ with vuetify.VCard(
+ classes="w-100 flex-grow-1 d-flex flex-column",
+ style="min-height: 0; min-width: 0; overflow: hidden;",
+ ):
+ with vuetify.VCardTitle("VTK Visualization", classes="d-flex flex-grow-0"):
+ vuetify.VSpacer()
+ # Token usage display
+ with vuetify.VChip(
+ small=True,
+ color="secondary",
+ text_color="white",
+ v_show="input_tokens > 0 || output_tokens > 0",
+ classes="mr-2",
+ density="compact",
+ ):
+ html.Span(
+ "Tokens: In: {{ input_tokens }} | Out: {{ output_tokens }}"
)
- app.ctrl.view_update = view.update
- app.ctrl.view_reset_camera = view.reset_camera
+ # VTK control buttons
+ with vuetify.VTooltip(
+ text="Clear Scene",
+ location="bottom",
+ ):
+ with vuetify.Template(v_slot_activator="{ props }"):
+ with vuetify.VBtn(
+ click=app.ctrl.clear_scene,
+ icon=True,
+ color="secondary",
+ v_bind="props",
+ classes="mr-2",
+ density="compact",
+ variant="text",
+ ):
+ vuetify.VIcon("mdi-reload")
+ with vuetify.VTooltip(
+ text="Reset Camera",
+ location="bottom",
+ ):
+ with vuetify.Template(v_slot_activator="{ props }"):
+ with vuetify.VBtn(
+ click=app.ctrl.reset_camera,
+ icon=True,
+ color="secondary",
+ v_bind="props",
+ classes="mr-2",
+ density="compact",
+ variant="text",
+ ):
+ vuetify.VIcon("mdi-camera-retake-outline")
+ with vuetify.VCardText(
+ classes="flex-grow-1 pa-0",
+ style="height: 0; min-height: 0;",
+ ):
+ # VTK render window
+ view = vtk_widgets.VtkRemoteView(
+ app.render_window,
+ ref="view",
+ classes="w-100 h-100",
+ interactor_settings=[
+ (
+ "SetInteractorStyle",
+ ["vtkInteractorStyleTrackballCamera"],
+ ),
+ ],
+ )
+ app.ctrl.view_update = view.update
+ app.ctrl.view_reset_camera = view.reset_camera
- # Register custom controller methods
- app.ctrl.on_tab_change = app.on_tab_change
+ # Register custom controller methods
+ app.ctrl.on_tab_change = app.on_tab_change
- # Ensure initial render
- view.update()
+ # Ensure initial render
+ view.update()
- # Conversation transcript: prompts and responses for the
- # active conversation. Click a turn to revisit that step.
- with vuetify.VCard(classes="h-25 w-100 mt-2"):
- vuetify.VCardTitle("Conversation", classes="text-h6")
- with vuetify.VCardText(
- classes="overflow-y-auto",
- style="height: calc(100% - 50px);",
- ):
- html.Div(
- "Your conversation will appear here...",
- v_show=(
- "conversation_navigation.length === 0 && !is_loading"
+ # Conversation transcript: prompts and responses for the
+ # active conversation. Click a turn to revisit that step.
+ with vuetify.VCard(
+ classes="w-100 mt-2 d-flex flex-column flex-grow-0",
+ style="height: 260px; min-height: 260px; overflow: hidden;",
+ ):
+ # Conversation and Console share this pane as tabs
+ # so they do not fight for vertical space.
+ with vuetify.VTabs(
+ v_model=("info_tab", "conversation"),
+ density="compact",
+ classes="flex-grow-0",
+ ):
+ vuetify.VTab("Conversation", value="conversation")
+ with vuetify.VTab(value="console"):
+ html.Span("Console")
+ vuetify.VChip(
+ "{{ console_log.length }}",
+ v_show=("console_log.length > 0",),
+ size="x-small",
+ variant="tonal",
+ classes="ml-2",
+ color=(
+ "console_level === 'err' ? 'error'"
+ " : console_level === 'warn' ? 'warning'"
+ " : undefined",
),
- classes="text-medium-emphasis text-body-2",
)
+ with vuetify.VCardText(
+ v_show=("info_tab !== 'console'",),
+ classes="overflow-y-auto flex-grow-1 pa-2",
+ style="height: 0; min-height: 0;",
+ ):
+ html.Div(
+ "Your conversation will appear here...",
+ v_show=(
+ "conversation_navigation.length === 0 && !is_loading"
+ ),
+ classes="text-medium-emphasis text-body-2",
+ )
+ with html.Div(
+ v_for="(pair, idx) in conversation_navigation",
+ key="'turn-' + idx",
+ classes="mb-2 pl-2",
+ style=(
+ "'border-left: 3px solid '"
+ + " + (conversation_index === idx"
+ + " ? 'rgb(var(--v-theme-primary))' : 'transparent')",
+ "",
+ ),
+ ):
with html.Div(
- v_for="(pair, idx) in conversation_navigation",
- key="'turn-' + idx",
- classes="mb-2 pl-2",
- style=(
- "'border-left: 3px solid '"
- + " + (conversation_index === idx"
- + " ? 'rgb(var(--v-theme-primary))' : 'transparent')",
- "",
- ),
+ classes="d-flex align-start",
+ click=(app.ctrl.navigate_to_conversation, "[idx]"),
+ style="cursor: pointer;",
):
- with html.Div(
- classes="d-flex align-start",
- click=(app.ctrl.navigate_to_conversation, "[idx]"),
- style="cursor: pointer;",
- ):
- vuetify.VIcon(
- "mdi-account-circle",
- size="small",
- color="primary",
- classes="mr-2",
- )
- html.Span(
- "{{ pair.prompt }}",
- classes=(
- "conversation_index === idx"
- + " ? 'text-body-2 font-weight-medium'"
- + " : 'text-body-2'",
- "text-body-2",
- ),
- )
- html.Div(
- "{{ pair.explanation }}",
- v_show="pair.explanation",
- classes="text-body-2 text-medium-emphasis ml-6",
- style="white-space: pre-wrap;",
+ vuetify.VIcon(
+ "mdi-account-circle",
+ size="small",
+ color="primary",
+ classes="mr-2",
)
- # Collapsible trace: the model's tool calls and
- # retries for this turn, when present.
- with vuetify.VExpansionPanels(
- v_show="pair.trace && pair.trace.length",
- variant="accordion",
- flat=True,
- classes="ml-6 mt-1",
- ):
- with vuetify.VExpansionPanel():
- vuetify.VExpansionPanelTitle(
- "Show work ({{ pair.trace.length }})",
- classes="text-caption pa-2",
- style="min-height: 0;",
- )
- with vuetify.VExpansionPanelText():
- with html.Div(
- v_for="(step, si) in pair.trace",
- key="'step-' + idx + '-' + si",
- classes="mb-2",
- ):
- html.Div(
- "{{ step.name }}{{ step.detail"
- + " ? ': ' + step.detail : '' }}",
- classes="text-caption font-weight-medium",
- )
- html.Div(
- "{{ step.result }}",
- v_show="step.result",
- classes="text-caption"
- + " text-medium-emphasis",
- style="white-space: pre-wrap;",
- )
- # Pending turn while a response is generating.
- with html.Div(
- v_show="is_loading && current_prompt",
- classes="mb-2 pl-2",
+ html.Span(
+ "{{ pair.prompt }}",
+ classes=(
+ "conversation_index === idx"
+ + " ? 'text-body-2 font-weight-medium'"
+ + " : 'text-body-2'",
+ "text-body-2",
+ ),
+ )
+ html.Div(
+ "{{ pair.explanation }}",
+ v_show="pair.explanation",
+ classes="text-body-2 text-medium-emphasis ml-6",
+ style="white-space: pre-wrap;",
+ )
+ # Collapsible trace: the model's tool calls and
+ # retries for this turn, when present.
+ with vuetify.VExpansionPanels(
+ v_show="pair.trace && pair.trace.length",
+ variant="accordion",
+ flat=True,
+ classes="ml-6 mt-1",
):
- with html.Div(classes="d-flex align-start"):
- vuetify.VIcon(
- "mdi-account-circle",
- size="small",
- color="primary",
- classes="mr-2",
- )
- html.Span(
- "{{ current_prompt }}",
- classes="text-body-2 font-weight-medium",
- )
- with html.Div(classes="d-flex align-center ml-6 mt-1"):
- vuetify.VProgressCircular(
- indeterminate=True,
- size="14",
- width="2",
- classes="mr-2",
+ with vuetify.VExpansionPanel():
+ vuetify.VExpansionPanelTitle(
+ "Show work ({{ pair.trace.length }})",
+ classes="text-caption pa-2",
+ style="min-height: 0;",
)
- html.Span(
- "Generating...",
- classes="text-body-2 text-medium-emphasis",
- )
-
- vuetify.VAlert(
- closable=True,
- v_show=("error_message", ""),
- density="compact",
- type="error",
- text=("error_message",),
- classes="h-auto position-absolute bottom-0 align-self-center mb-1",
- style="width: 30%; z-index: 1000;",
- icon="mdi-alert-outline",
- )
+ with vuetify.VExpansionPanelText():
+ with html.Div(
+ v_for="(step, si) in pair.trace",
+ key="'step-' + idx + '-' + si",
+ classes="mb-2",
+ ):
+ html.Div(
+ "{{ step.name }}{{ step.detail"
+ + " ? ': ' + step.detail : '' }}",
+ classes="text-caption font-weight-medium",
+ )
+ html.Div(
+ "{{ step.result }}",
+ v_show="step.result",
+ classes="text-caption"
+ + " text-medium-emphasis",
+ style="white-space: pre-wrap;",
+ )
+ # Pending turn while a response is generating.
+ with html.Div(
+ v_show="is_loading && current_prompt",
+ classes="mb-2 pl-2",
+ ):
+ with html.Div(classes="d-flex align-start"):
+ vuetify.VIcon(
+ "mdi-account-circle",
+ size="small",
+ color="primary",
+ classes="mr-2",
+ )
+ html.Span(
+ "{{ current_prompt }}",
+ classes="text-body-2 font-weight-medium",
+ )
+ with html.Div(classes="d-flex align-center ml-6 mt-1"):
+ vuetify.VProgressCircular(
+ indeterminate=True,
+ size="14",
+ width="2",
+ classes="mr-2",
+ )
+ html.Span(
+ "Generating...",
+ classes="text-body-2 text-medium-emphasis",
+ )
+ with vuetify.VCardText(
+ v_show=("info_tab === 'console'",),
+ classes="overflow-auto flex-grow-1 pa-2",
+ style=(
+ "height: 0; min-height: 0;"
+ " font-family: monospace; font-size: 12px;"
+ ),
+ ):
+ html.Div(
+ "No output yet.",
+ v_show=("console_lines.length === 0",),
+ classes="text-caption text-disabled",
+ )
+ with html.Div(
+ classes="d-flex justify-end",
+ v_show=("console_lines.length > 0",),
+ ):
+ vuetify.VBtn(
+ "Clear",
+ size="x-small",
+ variant="text",
+ click="console_lines = []; console_log = []",
+ )
+ # Wide non-wrapping lines scroll inside this fixed-
+ # width box instead of widening the page/layout.
+ with html.Div(
+ style="width: 100%; overflow-x: auto;",
+ ):
+ html.Div(
+ "{{ line.text }}",
+ v_for="(line, ci) in console_lines",
+ key="'c-' + ci",
+ classes=("line.cls",),
+ style="white-space: pre; width: max-content;",
+ )
# Toast notification snackbar for validation warnings
with vuetify.VSnackbar(
diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py
index 41980e9..55563f8 100644
--- a/src/vtk_prompt/ui/layout/conversation_history.py
+++ b/src/vtk_prompt/ui/layout/conversation_history.py
@@ -169,5 +169,24 @@ def build_conversation_history(app: Any) -> None:
classes="flex-grow-1 text-truncate",
style="cursor: pointer;",
)
+ # This conversation is generating, wherever you are.
+ vuetify.VProgressCircular(
+ indeterminate=True,
+ size="14",
+ width="2",
+ color="primary",
+ classes="ml-1",
+ v_show="s.generating",
+ )
+ # A conversation that finished while you were elsewhere.
+ with vuetify.VTooltip(text="New result", location="left"):
+ with vuetify.Template(v_slot_activator="{ props }"):
+ vuetify.VIcon(
+ "mdi-circle-medium",
+ v_bind="props",
+ size="small",
+ color="primary",
+ v_show="s.unseen && !s.generating",
+ )
_row_menu(app)
_dialogs(app)
diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py
index 48cbdf0..f7b1c88 100644
--- a/src/vtk_prompt/ui/layout/settings_dialog.py
+++ b/src/vtk_prompt/ui/layout/settings_dialog.py
@@ -190,6 +190,23 @@ def _advanced_tab() -> None:
hint="Context snippets retrieved per request",
persistent_hint=True,
)
+ vuetify.VCheckbox(
+ label="Log tool calls to the server console",
+ v_model=("log_tool_calls", False),
+ density="compact",
+ color="primary",
+ disabled=("!mcp_url",),
+ hide_details=True,
+ classes="mt-2",
+ )
+ vuetify.VCheckbox(
+ label="Agentic retrieval (use tools instead of pre-injected context)",
+ v_model=("agentic_retrieval", False),
+ density="compact",
+ color="primary",
+ disabled=("!mcp_url",),
+ hide_details=True,
+ )
vuetify.VDivider(classes="my-5")
_section("Generation")
diff --git a/src/vtk_prompt/utils/prompt_loader.py b/src/vtk_prompt/utils/prompt_loader.py
index 668d89a..8ab2480 100644
--- a/src/vtk_prompt/utils/prompt_loader.py
+++ b/src/vtk_prompt/utils/prompt_loader.py
@@ -120,6 +120,14 @@ def _process_rag_and_generation_settings(app: Any) -> None:
_mcp = app.custom_prompt_data.get("mcp_url")
if isinstance(_mcp, str):
app.state.mcp_url = _mcp.strip()
+ if "log_tool_calls" in app.custom_prompt_data:
+ _ltc = app.custom_prompt_data.get("log_tool_calls")
+ if isinstance(_ltc, bool):
+ app.state.log_tool_calls = _ltc
+ if "agentic_retrieval" in app.custom_prompt_data:
+ _ag = app.custom_prompt_data.get("agentic_retrieval")
+ if isinstance(_ag, bool):
+ app.state.agentic_retrieval = _ag
if "base_url" in app.custom_prompt_data:
_base = app.custom_prompt_data.get("base_url")
if isinstance(_base, str) and _base.strip():
diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py
index e057e09..5dd738f 100644
--- a/src/vtk_prompt/vtk_prompt_ui.py
+++ b/src/vtk_prompt/vtk_prompt_ui.py
@@ -113,11 +113,14 @@ def __init__(self, server: Any | None = None, custom_prompt_file: str | None = N
# Expose the live renderer/render_window to editor completion + hover, so
# the editor can complete e.g. renderer.AddActor and show their docstrings
# (same names the generated code's exec scope sees).
- from .completion import register_runtime_objects
+ from .completion import register_runtime_objects, warm_up
register_runtime_objects(
renderer=self.renderer, render_window=self.render_window
)
+ # Prime jedi's vtk analysis in the background so the first editor
+ # completion is fast and Monaco does not time out and close the popup.
+ warm_up()
# Initialize application state
self._initialize_state()
@@ -192,6 +195,11 @@ def run_current_code(self) -> None:
"""Execute the current (possibly edited) code without calling the LLM."""
generation.run_current_code(self)
+ @controller.set("apply_data_suggestion")
+ def apply_data_suggestion(self, missing: str, suggestion: str) -> None:
+ """Swap an unresolved data-file reference for a chosen one and re-run."""
+ generation.apply_data_suggestion(self, missing, suggestion)
+
@controller.set("undo_code")
def undo_code(self) -> None:
"""Revert the code panel to the previous version and re-render."""
@@ -352,13 +360,6 @@ def toggle_favorite_conversation(self, conversation_index: int) -> None:
def start_new_conversation(self) -> None:
"""Archive the current conversation and start a fresh one."""
sessions.new_session(self)
- # A fresh conversation has no scene yet, so reset the render window to
- # the default (empty) state instead of leaving the previous result.
- from .rendering import clear_scene
-
- clear_scene(self.renderer, self.render_window)
- if self.ctrl.view_update:
- self.ctrl.view_update()
@controller.set("switch_session")
def switch_session(self, session_id: str) -> None:
diff --git a/tests/test_console_classify.py b/tests/test_console_classify.py
new file mode 100644
index 0000000..1264189
--- /dev/null
+++ b/tests/test_console_classify.py
@@ -0,0 +1,53 @@
+"""Tests for console line severity classification."""
+
+import pytest
+
+from vtk_prompt.controllers.generation import _classify_line
+
+
+@pytest.mark.parametrize(
+ "text,kind",
+ [
+ ("blabla True", "out"),
+ ("Building sphere...", "out"),
+ ("Warning: deprecated API", "warn"),
+ ("DeprecationWarning: use X", "warn"),
+ ("vtkDebugLeaks Warning: leaked", "warn"),
+ ("ERROR: could not open file", "err"),
+ ("Exodus Library Warning/Error: [x]", "err"),
+ ("Traceback (most recent call last):", "err"),
+ (' File "", line 3', "err"),
+ ],
+)
+def test_classify_line(text, kind):
+ assert _classify_line(text) == kind
+
+
+def test_stdout_is_never_error_even_with_error_words(monkeypatch):
+ """A printed class name like vtkErrorCode must not be flagged as an error."""
+ import types
+ from vtk_prompt.controllers import generation
+
+ app = types.SimpleNamespace()
+ app.state = types.SimpleNamespace(console_log=[], console_lines=[])
+ generation._append_console(
+ app, stdout="['vtkErrorCode', 'vtkWarningObserver']", stderr="", error=None
+ )
+ run = app.state.console_log[-1]
+ assert run["level"] == "out"
+ assert all(line["kind"] == "out" for line in run["lines"])
+
+
+def test_stderr_is_warning_and_exception_is_error():
+ import types
+ from vtk_prompt.controllers import generation
+
+ app = types.SimpleNamespace()
+ app.state = types.SimpleNamespace(console_log=[], console_lines=[])
+ generation._append_console(app, stdout="", stderr="deprecated call", error=None)
+ assert app.state.console_log[-1]["level"] == "warn"
+
+ app.state.console_log = []
+ app.state.console_lines = []
+ generation._append_console(app, stdout="", stderr="", error="Traceback: boom")
+ assert app.state.console_log[-1]["level"] == "err"
diff --git a/tests/test_data_suggestions.py b/tests/test_data_suggestions.py
new file mode 100644
index 0000000..e9ab4c7
--- /dev/null
+++ b/tests/test_data_suggestions.py
@@ -0,0 +1,55 @@
+"""Tests for near-miss data-file suggestions in the resolver."""
+
+from vtk_prompt.data import resolver
+
+
+def _index(monkeypatch, names):
+ monkeypatch.setattr(resolver, "_load_index", lambda: {n: "hash" for n in names})
+ monkeypatch.setattr(resolver.uploads, "uploaded_names", lambda: [])
+
+
+def test_stem_match_is_suggested(monkeypatch):
+ _index(monkeypatch, ["can.ex2", "can.exdg", "other.vtk"])
+ out = resolver.suggestions("r.SetFileName('can.ex')")
+ assert out and out[0]["name"] == "can.ex"
+ assert "can.ex2" in out[0]["matches"]
+
+
+def test_valid_reference_has_no_suggestion(monkeypatch):
+ _index(monkeypatch, ["can.ex2"])
+ assert resolver.suggestions("r.SetFileName('can.ex2')") == []
+
+
+def test_paths_and_code_are_ignored(monkeypatch):
+ _index(monkeypatch, ["can.ex2"])
+ assert resolver.suggestions("open('/abs/can.ex')") == []
+ assert resolver.suggestions("print('hello world')") == []
+ assert resolver.suggestions("f'{name}.vtk'") == []
+
+
+def test_no_index_no_suggestions(monkeypatch):
+ _index(monkeypatch, [])
+ assert resolver.suggestions("r.SetFileName('can.ex')") == []
+
+
+def test_apply_swaps_reference_in_current_code(monkeypatch):
+ import types
+ from vtk_prompt.controllers import generation
+
+ calls = []
+ app = types.SimpleNamespace()
+ app.state = types.SimpleNamespace(
+ generated_code="r.SetFileName('can.ex')",
+ code_history=["r.SetFileName('can.ex')"],
+ code_history_labels=["gen"],
+ code_history_pos=0,
+ data_suggestions=[{"missing": "can.ex", "suggestion": "can.ex2"}],
+ error_message="nope",
+ )
+ monkeypatch.setattr(generation, "push_code_snapshot", lambda a, c, label="": calls.append(("snap", c)))
+ monkeypatch.setattr(generation, "execute_with_renderer", lambda a, c: calls.append(("run", c)))
+ generation.apply_data_suggestion(app, "can.ex", "can.ex2")
+ assert "'can.ex2'" in app.state.generated_code
+ assert app.state.data_suggestions == []
+ assert app.state.error_message == ""
+ assert ("run", "r.SetFileName('can.ex2')") in calls