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