From 5446a131ac6712921995a3809fe17332a7a7aec5 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 31 Jul 2026 21:32:58 -0400 Subject: [PATCH 1/4] Add MCP tool-call support for local and text-emitting backends Run tool calls that local backends emit as inline text (for models that do not support native tool calling), add optional logging of vtk-mcp tool calls with a persisted setting, and add an agentic-retrieval toggle that skips pre-injected context so the model drives retrieval itself. --- src/vtk_prompt/client.py | 92 ++++++++++++++++++++- src/vtk_prompt/controllers/configuration.py | 4 + src/vtk_prompt/controllers/generation.py | 4 + src/vtk_prompt/state/initializer.py | 2 + src/vtk_prompt/ui/layout/settings_dialog.py | 17 ++++ src/vtk_prompt/utils/prompt_loader.py | 8 ++ 6 files changed, 125 insertions(+), 2 deletions(-) diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index 3177d6c..e395801 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -33,6 +33,40 @@ logger = get_logger(__name__) +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: + 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 + + @dataclass class VTKPromptClient: """OpenAI client for VTK code generation.""" @@ -296,6 +330,8 @@ 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, ) -> tuple[str, str, Any] | tuple[str, str, Any, list[str]] | str: """Generate VTK code using vtk-mcp tools when available. @@ -359,7 +395,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 @@ -417,6 +454,11 @@ def query( # 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): @@ -468,18 +510,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]) + 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]) self.conversation.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) + ] + self.conversation.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]) + self.conversation.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 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/generation.py b/src/vtk_prompt/controllers/generation.py index 576f0ed..72e728f 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -103,6 +103,8 @@ 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 @@ -176,6 +178,8 @@ 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, diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 74ae8b7..be26236 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -51,6 +51,8 @@ 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.input_tokens = 0 app.state.output_tokens = 0 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(): From f95569fb59514b79279b2224804dae38e7bd67ce Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 31 Jul 2026 21:33:41 -0400 Subject: [PATCH 2/4] Support concurrent conversations, each owning its own state Make the prompt client stateless so each conversation (session) owns its messages, then let conversations generate concurrently: each generation is tied to its originating conversation and its result, error, and rendered scene are delivered there rather than to whatever the user is currently viewing. The drawer shows which conversation is generating, the in-pane spinner tracks the visible conversation, and returning to a conversation that is still generating no longer shows a stale result. Each conversation restores its own scene on switch, a new conversation starts idle, titles come from its own first prompt, and the render window is cleared wherever a conversation is reset. Generated code can no longer open a native render window or corrupt the app's window, and the editor remounts per conversation. --- src/vtk_prompt/cli.py | 12 +- src/vtk_prompt/client.py | 139 ++++++-------- src/vtk_prompt/controllers/conversation.py | 5 - src/vtk_prompt/controllers/generation.py | 171 +++++++++++++++--- src/vtk_prompt/controllers/sessions.py | 106 ++++++++--- src/vtk_prompt/rendering/code_executor.py | 53 +++++- src/vtk_prompt/state/initializer.py | 6 +- src/vtk_prompt/ui/layout/content.py | 5 + .../ui/layout/conversation_history.py | 19 ++ src/vtk_prompt/vtk_prompt_ui.py | 7 - 10 files changed, 371 insertions(+), 152 deletions(-) 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 e395801..562a37a 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -67,73 +67,45 @@ def _parse_text_tool_calls(content: str | None, tool_names: set[str]) -> list[di return calls or None -@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(): +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 [] - 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) - return [] - def save_conversation(self) -> None: - """Save conversation history to file.""" - if not self.conversation_file or not self.conversation: - return +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) - try: - # Ensure directory exists - Path(self.conversation_file).parent.mkdir(parents=True, exist_ok=True) - 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) +@dataclass +class VTKPromptClient: + """Stateless OpenAI client for VTK code generation. - 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) + 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. + """ - if new_convo_file: - self.conversation_file = new_convo_file + 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.""" @@ -332,6 +304,7 @@ def query( 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. @@ -357,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) @@ -379,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": ( @@ -447,10 +420,10 @@ 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 [] @@ -474,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] @@ -501,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: @@ -519,7 +492,7 @@ def query( ) else: logger.debug("Tool %s -> %s...", tc.function.name, result[:80]) - self.conversation.append( + messages.append( {"role": "tool", "tool_call_id": tc.id, "content": result} ) continue # let LLM decide what to do next @@ -540,7 +513,7 @@ def query( } for i, c in enumerate(text_calls) ] - self.conversation.append(text_msg) + 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: @@ -552,7 +525,7 @@ def query( ) else: logger.debug("Tool %s (text) -> %s...", c["name"], result[:80]) - self.conversation.append( + messages.append( {"role": "tool", "tool_call_id": f"call_{i}", "content": result} ) continue @@ -588,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": ( @@ -614,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": ( @@ -631,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, @@ -645,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": ( @@ -659,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/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 72e728f..6d1811c 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -26,6 +26,105 @@ ) +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): + app.state.error_message = 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 +133,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 +144,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 +160,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 +181,22 @@ 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: 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 +204,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(), @@ -109,10 +224,16 @@ 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 + 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] = [] @@ -168,8 +289,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(), @@ -184,7 +307,7 @@ async def generate_and_execute_code(app: Any) -> None: 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: @@ -202,16 +325,20 @@ 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 = ( - f"{str(e)} Current: {app.state.max_tokens}. Try increasing max tokens." - ) + 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 diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index 5c44985..3d1e97d 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -49,6 +49,26 @@ def _new_session() -> dict: } +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 +95,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) @@ -112,12 +145,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 +161,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 +177,19 @@ def _reset_live(app: Any) -> None: app.state.code_history = [] app.state.code_history_labels = [] app.state.code_history_pos = -1 + # 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 +203,26 @@ 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 + # An error belongs to its conversation, so it surfaces on switching to it. + app.state.error_message = sess.get("error_message", "") or "" + 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 "" from .conversation import ( _parse_assistant_content, @@ -196,14 +251,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/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py index 2be9aaa..02e10ef 100644 --- a/src/vtk_prompt/rendering/code_executor.py +++ b/src/vtk_prompt/rendering/code_executor.py @@ -10,6 +10,44 @@ logger = get_logger(__name__) +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 ) -> tuple[bool, str | None, str | None]: @@ -41,8 +79,19 @@ def execute_vtk_code( "__name__": "__main__", } - # Execute the code - exec(code_segment, exec_globals) + # 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() until the user pressed q. Hand it the app's render window and an + # inert interactor instead, then restore vtk for everyone else. + real_window_cls = vtk.vtkRenderWindow + real_interactor_cls = vtk.vtkRenderWindowInteractor + vtk.vtkRenderWindow = _NoOpRenderWindow # type: ignore[assignment,misc] + vtk.vtkRenderWindowInteractor = _NoOpInteractor # type: ignore[assignment,misc] + try: + exec(code_segment, exec_globals) + finally: + vtk.vtkRenderWindow = real_window_cls # type: ignore[assignment,misc] + vtk.vtkRenderWindowInteractor = real_interactor_cls # type: ignore[assignment,misc] # Reset camera and render try: diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index be26236..2044bb8 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -168,10 +168,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..4321b5b 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -137,6 +137,11 @@ def build_content(layout: Any, app: Any) -> None: ) with vuetify.VCardText(style="height: calc(100% - 50px);"): code.Editor( + # Remount per conversation: switching swaps the + # contents, and the editor would otherwise keep a + # cursor pointing past the end of the new code + # ("Illegal value for lineNumber"). + key=("current_session_id",), v_model=("generated_code", ""), language="python", theme="vs", 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/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index e057e09..6f154ff 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -352,13 +352,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: From 17087782519d8eda69f9ca300172d0558f32a76d Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 31 Jul 2026 21:34:28 -0400 Subject: [PATCH 3/4] Add a console panel and data-file suggestions Capture stdout and stderr from running generated code (previously only visible in the server terminal) and show them in a per-conversation Console tab beside the transcript. Lines are classified by stream, not text content: stdout is ordinary output regardless of content (a printed class name like vtkErrorCode is not an error), stderr is a warning, and a raised exception is an error. Long lines are truncated and scroll within a width-contained box so large output cannot blow out the layout, and the console is stored on the session so it is per-conversation. The console is now the single record of a run: run errors, generation and config errors, and the data-file resolver's "could not resolve" suggestion (as a warning) all appear there, and the floating alert is removed; the Console tab badge is coloured by the latest run's severity so failures are noticeable from the transcript. When a data-file reference cannot be resolved, offer the closest known names as one-click fixes via a "Fix data file" toolbar menu. The code editor remounts when its contents change (conversation switch or code version) so it never keeps a cursor past the end of shorter content. --- src/vtk_prompt/controllers/generation.py | 161 +++++++- src/vtk_prompt/controllers/sessions.py | 24 +- src/vtk_prompt/data/resolver.py | 42 ++ src/vtk_prompt/rendering/code_executor.py | 52 ++- src/vtk_prompt/state/initializer.py | 6 + src/vtk_prompt/ui/layout/content.py | 477 +++++++++++++--------- src/vtk_prompt/vtk_prompt_ui.py | 5 + tests/test_console_classify.py | 53 +++ tests/test_data_suggestions.py | 55 +++ 9 files changed, 667 insertions(+), 208 deletions(-) create mode 100644 tests/test_console_classify.py create mode 100644 tests/test_data_suggestions.py diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 6d1811c..0b7e791 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -77,7 +77,8 @@ def _deliver_error(app: Any, session_id: str, message: str) -> None: interrupting whatever they are looking at now. """ if _is_visible(app, session_id): - app.state.error_message = message + # The console is the single record now; no floating alert. + console_message(app, message) return from . import sessions as sessions_mod @@ -191,7 +192,11 @@ async def generate_and_execute_code(app: Any, origin_session_id: str = "") -> No # 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 its conversation by token. The result is # delivered only if this conversation has not been reset or @@ -325,7 +330,9 @@ async def generate_and_execute_code(app: Any, origin_session_id: str = "") -> No execute_with_renderer(app, app.state.generated_code) except ValueError as e: if "max_tokens" in str(e): - msg = f"{str(e)} Current: {app.state.max_tokens}. Try increasing max tokens." + msg = ( + f"{str(e)} Current: {app.state.max_tokens}. Try increasing max tokens." + ) else: msg = f"Error generating code: {str(e)}" _deliver_error(app, origin_session_id, msg) @@ -362,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 @@ -374,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 3d1e97d..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,6 +47,9 @@ def _new_session() -> dict: "code_history_labels": [], "code_history_pos": -1, "checkpoints": [], + "console_log": [], + "console_lines": [], + "console_level": "out", } @@ -130,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) @@ -177,6 +184,9 @@ 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()) @@ -209,8 +219,7 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> 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 - # An error belongs to its conversation, so it surfaces on switching to it. - app.state.error_message = sess.get("error_message", "") or "" + app.state.error_message = "" if sess.get("unseen"): sess["unseen"] = False refresh_sessions_list(app) # drop the new-result marker now it is seen @@ -223,6 +232,17 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None: 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, 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 02e10ef..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,24 @@ 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. @@ -79,30 +99,38 @@ def execute_vtk_code( "__name__": "__main__", } - # Keep generated code inside the app: a script that builds its own window + # 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() until the user pressed q. Hand it the app's render window and an - # inert interactor instead, then restore vtk for everyone else. + # 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: - exec(code_segment, exec_globals) + 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 # type: ignore[assignment,misc] - - # Reset camera and render - try: - renderer.ResetCamera() - render_window.Render() - except Exception as render_error: - logger.warning("Render error: %s", render_error) + 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 2044bb8..56bc35a 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -54,6 +54,12 @@ def initialize_state(app: Any) -> None: 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 diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 4321b5b..572af62 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,11 +169,17 @@ def build_content(layout: Any, app: Any) -> None: ) with vuetify.VCardText(style="height: calc(100% - 50px);"): code.Editor( - # Remount per conversation: switching swaps the - # contents, and the editor would otherwise keep a - # cursor pointing past the end of the new code - # ("Illegal value for lineNumber"). - key=("current_session_id",), + # Remount whenever the editor's contents are + # swapped out from under it: either switching + # conversation or moving to a different code + # version (generate/undo/redo). Otherwise the + # editor keeps a cursor pointing past the end of + # shorter content ("Illegal value for + # lineNumber"). + key=( + "current_session_id + '-' + code_history.length" + " + '-' + code_history_pos", + ), v_model=("generated_code", ""), language="python", theme="vs", @@ -253,199 +291,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/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 6f154ff..119be28 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -192,6 +192,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.""" 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 From 29ad8bf0911ab1423f71ef3e063ae388bb2cc489 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Mon, 3 Aug 2026 13:38:51 -0400 Subject: [PATCH 4/4] Key the editor on conversation only, to not break autocomplete The composite editor key (session + code-history length + position) remounted the editor on generate/undo/redo. When that happened while an autocomplete popup was open, trame-code disposed the completion widget mid-flight and threw (null read in dispose); the dropdown flashed and vanished. Key on current_session_id alone, matching the concurrent-conversations behaviour, so the editor only remounts on a conversation switch (when no popup is open). --- src/vtk_prompt/completion.py | 23 +++++++++++++++++++++++ src/vtk_prompt/ui/layout/content.py | 17 ++++++----------- src/vtk_prompt/vtk_prompt_ui.py | 5 ++++- 3 files changed, 33 insertions(+), 12 deletions(-) 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/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 572af62..b5cee70 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -169,17 +169,12 @@ def build_content(layout: Any, app: Any) -> None: ) with vuetify.VCardText(style="height: calc(100% - 50px);"): code.Editor( - # Remount whenever the editor's contents are - # swapped out from under it: either switching - # conversation or moving to a different code - # version (generate/undo/redo). Otherwise the - # editor keeps a cursor pointing past the end of - # shorter content ("Illegal value for - # lineNumber"). - key=( - "current_session_id + '-' + code_history.length" - " + '-' + code_history_pos", - ), + # 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", diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 119be28..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()