diff --git a/src/vtk_prompt/completion.py b/src/vtk_prompt/completion.py index a21aeba..b5f0f8e 100644 --- a/src/vtk_prompt/completion.py +++ b/src/vtk_prompt/completion.py @@ -13,6 +13,10 @@ from __future__ import annotations +import re +from functools import lru_cache +from types import ModuleType + from . import get_logger logger = get_logger(__name__) @@ -84,6 +88,15 @@ def complete_python(code: str, line: int, column: int, limit: int = 500) -> list """ if not _JEDI_OK or not isinstance(code, str): return [] + + # Cheap path first, same reasoning as hover: a resolvable VTK receiver is + # answered from docstrings without waiting on jedi's inference. + cls = _resolve_receiver(code, line, column, allow_jedi=False) + if cls is not None: + members = _members_of(cls, limit) + if members: + return members + try: completions = jedi.Interpreter(code, [_NS]).complete(line, column) except Exception as exc: # jedi can raise on malformed/partial input @@ -97,6 +110,13 @@ def complete_python(code: str, line: int, column: int, limit: int = 500) -> list except Exception: detail = "" out.append({"label": c.name, "kind": c.type, "detail": detail}) + + if not out: + # jedi gives nothing after a VTK call ("GetPointIds()."), so retry with + # jedi allowed to infer the chain's root. + cls = _resolve_receiver(code, line, column, allow_jedi=True) + if cls is not None: + return _members_of(cls, limit) return out @@ -111,6 +131,195 @@ def _doc_prose(doc: str, name: str) -> str: return "\n".join(kept).strip() +# -------------------------------------------------------------------------- +# Docstring-based return-type resolution +# +# jedi cannot infer through VTK's C-extension methods. It *shows* the return +# type because it parses the docstring, but it does not use it for inference, so +# any chained expression dies at the first call: `tetra.GetPointIds().SetId` has +# no completions and no hover, even though `tetra.GetPointIds` resolves fine. +# +# VTK's docstrings do carry the type ("GetPointIds(self) -> vtkIdList"), so the +# chain can be walked manually: resolve the root with jedi, then step through +# each `.Method()` by reading the annotation and looking the class up in the vtk +# namespace. Only VTK object returns resolve, which is the intent - `-> int` or +# `-> None` correctly yields nothing to chain from. +# -------------------------------------------------------------------------- + +# "-> vtkIdList", "-> ('vtkIdList', ...)", "-> None" +_RETURN_ANNOTATION = re.compile(r"->\s*\(?\s*'?([A-Za-z_][\w.]*)'?") +# Trailing `name.Method().Method()` chain immediately left of the cursor. The +# final identifier is optional: completion fires straight after the "." with +# nothing typed, while hover sits inside a name that is already there. +_CHAIN = re.compile(r"([A-Za-z_]\w*)\s*((?:\.\s*[A-Za-z_]\w*\s*\([^()]*\)\s*)+)\.\s*\w*$") +_CHAIN_STEP = re.compile(r"\.\s*([A-Za-z_]\w*)\s*\([^()]*\)") +# `name.token` with no intervening call - the direct-attribute case. +_DIRECT = re.compile(r"([A-Za-z_]\w*)\s*\.\s*\w*$") + + +def _assigned_vtk_class(code: str, root: str) -> type | None: + """Class from a literal ``root = vtk.vtkFoo()`` assignment in the source. + + Covers the dominant shape of generated VTK scripts without paying for jedi + inference, which costs hundreds of milliseconds even with a warm cache. + """ + m = re.search( + rf"^\s*{re.escape(root)}\s*=\s*(?:[\w.]*\.)?(vtk[A-Za-z0-9_]*)\s*\(", + code, + re.MULTILINE, + ) + return _vtk_class(m.group(1)) if m else None + + +def _live_class(root: str) -> type | None: + """Class of an injected runtime object (``renderer``, ``render_window``). + + Modules and classes are excluded: ``vtk`` is in the namespace too, and + ``type()`` of a module is useless here. + """ + live = _NS.get(root) + if live is None or isinstance(live, (type, ModuleType)): + return None + cls = type(live) + return cls if cls.__name__.startswith("vtk") else None + + +@lru_cache(maxsize=4096) +def _return_class(cls: type, method: str) -> type | None: + """Class returned by ``cls.method()`` per its docstring, or None. + + Cached because hover fires on mouse movement; the parsing is cheap but not + free at that rate. + """ + func = getattr(cls, method, None) + doc = getattr(func, "__doc__", "") or "" + for line in doc.splitlines(): + if not line.strip().startswith(method + "("): + continue + m = _RETURN_ANNOTATION.search(line) + if not m: + continue + name = m.group(1).split(".")[-1] + if not name.startswith("vtk"): + return None # int/float/None etc: nothing to chain from + return _vtk_class(name) + return None + + +def _vtk_class(name: str) -> type | None: + """Look a VTK class up by bare name in the seeded namespace.""" + for container in (_NS.get("vtk"), _NS.get("vtkmodules")): + obj = getattr(container, name, None) + if isinstance(obj, type): + return obj + obj = _NS.get(name) + return obj if isinstance(obj, type) else None + + +def _root_class(code: str, line: int, column: int, root: str, allow_jedi: bool) -> type | None: + """Class of the chain's root variable, cheapest source first. + + Injected runtime objects and literal ``x = vtk.vtkFoo()`` assignments are + resolved without jedi, which is the whole point: jedi's inference is what + makes hover slow enough for the editor to give up waiting. + """ + cls = _live_class(root) or _assigned_vtk_class(code, root) + if cls is not None or not allow_jedi or not _JEDI_OK: + return cls + try: + for d in jedi.Interpreter(code, [_NS]).infer(line, column): + obj = _vtk_class(d.name) + if obj is not None: + return obj + except Exception as exc: + logger.debug("jedi root inference error: %s", exc) + return None + + +def _resolve_receiver(code: str, line: int, column: int, allow_jedi: bool = True) -> type | None: + """Class of the expression immediately left of the cursor, or None. + + Handles both ``tetra.GetPointIds().SetId`` (walking the chain by docstring + return type) and plain ``renderer.AddActor``. + """ + lines = code.splitlines() + if line < 1 or line > len(lines): + return None + text = lines[line - 1] + prefix = text[: column + 1] + # Include the identifier the cursor sits inside, not just what precedes it. + trailing = re.match(r"\w*", text[column + 1 :]) + prefix += trailing.group(0) if trailing else "" + + m = _CHAIN.search(prefix) + steps_text = m.group(2) if m else "" + if m is None: + m = _DIRECT.search(prefix) + if m is None: + return None + root = m.group(1) + # Column of the root token, so jedi infers the variable and not the chain. + cls = _root_class(code, line, prefix.index(root) + len(root), root, allow_jedi) + if cls is None: + return None + for step in _CHAIN_STEP.findall(steps_text): + cls = _return_class(cls, step) + if cls is None: + return None + return cls + + +def _members_of(cls: type, limit: int) -> list[dict]: + """Completion candidates for a resolved class, in jedi's output shape.""" + out: list[dict] = [] + for name in dir(cls): + if name.startswith("_"): + continue + try: + attr = getattr(cls, name, None) + doc = (getattr(attr, "__doc__", "") or "").splitlines() + detail = next((ln.strip() for ln in doc if ln.strip()), "")[:80] + kind = "function" if callable(attr) else "instance" + except Exception: + detail, kind = "", "instance" + out.append({"label": name, "kind": kind, "detail": detail}) + if len(out) >= limit: + break + return out + + +def _hover_from_class(cls: type, name: str) -> dict | None: + """Hover payload for ``cls.name``, matching hover_python's shape.""" + attr = getattr(cls, name, None) + if attr is None: + return None + doc = getattr(attr, "__doc__", "") or "" + if not doc: + return None + signatures = [ln.strip() for ln in doc.splitlines() if ln.strip().startswith(name + "(")] + return { + "name": name, + "type": "function" if callable(attr) else "instance", + "signatures": signatures, + "prose": _doc_prose(doc, name), + } + + +def _token_at(code: str, line: int, column: int) -> str: + """Return the identifier the cursor sits in or next to.""" + lines = code.splitlines() + if line < 1 or line > len(lines): + return "" + text = lines[line - 1] + start = column + while start > 0 and (text[start - 1].isalnum() or text[start - 1] == "_"): + start -= 1 + end = column + while end < len(text) and (text[end].isalnum() or text[end] == "_"): + end += 1 + return text[start:end] + + def hover_python(code: str, line: int, column: int) -> dict | None: """Return hover info (signature + docstring) for the symbol at the cursor. @@ -120,12 +329,30 @@ def hover_python(code: str, line: int, column: int) -> dict | None: """ if not _JEDI_OK or not isinstance(code, str): return None + + # Try the docstring route before jedi. For a VTK receiver it answers in + # ~1ms where jedi's help() takes hundreds of milliseconds warm and seconds + # cold - long enough that the editor cancels the request and shows nothing, + # which looked like "hover is broken" rather than "hover is slow". + token = _token_at(code, line, column) + if token: + cls = _resolve_receiver(code, line, column, allow_jedi=False) + if cls is not None: + info = _hover_from_class(cls, token) + if info is not None: + return info + try: defs = jedi.Interpreter(code, [_NS]).help(line, column) except Exception as exc: logger.debug("jedi hover error: %s", exc) return None if not defs: + # Nothing from jedi either: retry the chain with jedi allowed to infer + # the root, for receivers the cheap paths cannot see. + cls = _resolve_receiver(code, line, column, allow_jedi=True) + if cls is not None and token: + return _hover_from_class(cls, token) return None d = defs[0] try: diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py index a4e4433..e2c6472 100644 --- a/src/vtk_prompt/controllers/configuration.py +++ b/src/vtk_prompt/controllers/configuration.py @@ -9,8 +9,82 @@ import yaml +from .. import get_logger from ..provider_utils import DEFAULT_PROVIDER, get_default_model, supports_temperature +logger = get_logger(__name__) + +# Settings that belong to the install rather than to a conversation, and so are +# written back to the config file. Everything else in that file is left alone: +# `model` and `base_url` follow the active conversation now, and `retries` / +# `modelParameters` are per-conversation too, so writing them here would let +# whichever conversation happened to be open redefine the startup defaults. +_GLOBAL_SETTING_KEYS = ( + "mcp_url", + "top_k", + "log_tool_calls", + "agentic_retrieval", + "data_root", +) + + +def _global_settings_path(): + """Return the config file to write global settings to. + + Prefers whichever file was discovered at startup so edits land back in the + file they came from, and falls back to the per-user location on a fresh + install where no config exists yet. + """ + from pathlib import Path + + from ..utils.env_config import _config_home, discover_config_file + + found = discover_config_file() + if found: + return Path(found) + return _config_home() / "config.yml" + + +def persist_global_settings(app: Any) -> None: + """Write the install-wide settings back to the config file. + + Read-modify-write rather than a full dump: the file is hand-maintained and + holds keys this app never sets (``name``, ``description``), so only the keys + in ``_GLOBAL_SETTING_KEYS`` are touched and existing order is preserved. + """ + path = _global_settings_path() + try: + existing = {} + if path.is_file(): + loaded = yaml.safe_load(path.read_text()) + if isinstance(loaded, dict): + existing = loaded + + updated = dict(existing) + for key in _GLOBAL_SETTING_KEYS: + if not hasattr(app.state, key): + continue + value = getattr(app.state, key) + if key == "top_k": + value = int(value) + elif key in ("log_tool_calls", "agentic_retrieval"): + value = bool(value) + else: + value = (value or "").strip() + if not value and key not in existing: + continue # don't add empty keys the file never had + updated[key] = value + + if updated == existing: + return # nothing changed; leave the file's mtime alone + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(updated, sort_keys=False)) + logger.info("Saved global settings to %s", path) + except (OSError, yaml.YAMLError, TypeError, ValueError) as e: + # Never let a settings write break closing the dialog. + logger.warning("Could not save global settings to %s: %s", path, e) + def on_tab_change(app: Any, tab_index: int, **_: Any) -> None: """Handle tab change to sync use_cloud_models state.""" @@ -18,11 +92,29 @@ def on_tab_change(app: Any, tab_index: int, **_: Any) -> None: def on_model_change(app: Any, **_: Any) -> None: - """Handle model change to update temperature support.""" + """Sync temperature support for the selected model. + + Models that ignore temperature need it pinned to 1, but that used to + overwrite the user's value with no way back: switching to o3 and back left + the conversation at 1 forever. The pre-clamp value is stashed instead, and + restored on the way out. + """ current_model = app._get_model() - app.state.temperature_supported = supports_temperature(current_model) - if not app.state.temperature_supported: + supported = supports_temperature(current_model) + app.state.temperature_supported = supported + + if not supported: + # Stash only on the way in, or a second model change while clamped + # would overwrite the stash with the clamped value itself. + if not str(getattr(app.state, "temperature_pref", "") or ""): + app.state.temperature_pref = str(app.state.temperature) app.state.temperature = 1 + return + + stashed = str(getattr(app.state, "temperature_pref", "") or "") + if stashed: + app.state.temperature = stashed + app.state.temperature_pref = "" def on_provider_change(app: Any, provider: str, **kwargs: Any) -> None: diff --git a/src/vtk_prompt/controllers/model_config.py b/src/vtk_prompt/controllers/model_config.py new file mode 100644 index 0000000..895a6ba --- /dev/null +++ b/src/vtk_prompt/controllers/model_config.py @@ -0,0 +1,132 @@ +"""Per-conversation model configuration. + +Each conversation remembers which model it uses (cloud/local, provider, model +name, endpoint, key). The active selection is snapshotted onto the session when +switching away and applied when switching in. A new conversation inherits the +last-used selection simply by copying whatever is currently active, so "new +conversations default to the last used model" needs no extra bookkeeping. +""" + +from typing import Any + +# Which model a conversation talks to: the endpoint and credentials. +MODEL_IDENTITY_FIELDS = ( + "use_cloud_models", + "provider", + "model", + "local_base_url", + "local_model", + "api_token", + "temperature_supported", +) + +# How that model is driven: the "Generation" section of the settings dialog. +# These belong to the conversation for the same reason the model does - a +# conversation tuned for terse deterministic output shouldn't have its +# temperature moved by work done in another tab. +GENERATION_PARAM_FIELDS = ( + "temperature", + "max_tokens", + "retry_attempts", + # Travels with the conversation so a clamped temperature can be restored in + # the conversation it was clamped in, not whichever one is open later. + "temperature_pref", +) + +# The full per-conversation cluster, in apply order. Identity must come first: +# writing `model` fires the @change("model") hook, which clamps temperature for +# models that don't support it, so the params have to settle afterwards. +# +# Deliberately global (not per-conversation): mcp_url, top_k, log_tool_calls, +# agentic_retrieval, data_root, uploaded_files. +MODEL_CONFIG_FIELDS = MODEL_IDENTITY_FIELDS + GENERATION_PARAM_FIELDS + + +def snapshot_model_config(app: Any) -> dict: + """Read the active model-config cluster off live state into a plain dict.""" + return {f: getattr(app.state, f, None) for f in MODEL_CONFIG_FIELDS} + + +def apply_model_config(app: Any, cfg: dict) -> None: + """Write a saved model-config cluster back onto live state. + + Missing keys are left untouched so an older session file (saved before this + feature) keeps whatever is currently active rather than blanking the model. + An empty ``api_token`` is treated the same way: a conversation saved without + a token should fall back to the environment-seeded one rather than clearing + it for everything opened afterwards. + """ + if not cfg: + return + for f in MODEL_CONFIG_FIELDS: + if f not in cfg or cfg[f] is None: + continue + if f == "api_token" and not str(cfg[f]).strip(): + continue + setattr(app.state, f, cfg[f]) + + +def build_model_options(app: Any) -> list: + """Flat, display-ready list of selectable models for the toolbar picker. + + Each entry is a plain dict with precomputed fields so the Vue template binds + simple values (no nested v-for or inline expressions, which have historically + produced malformed markup): + {"key", "label", "provider", "model", "cloud"} + Cloud models come from the curated per-provider lists; a single "local" + entry represents the configured local endpoint. + """ + opts: list = [] + available = getattr(app.state, "available_models", {}) or {} + for provider in sorted(available.keys()): + for model in available[provider]: + opts.append( + { + "key": f"cloud:{provider}:{model}", + "label": f"{provider} / {model}", + "provider": provider, + "model": model, + "cloud": True, + } + ) + local_model = getattr(app.state, "local_model", "") or "default" + opts.append( + { + "key": "local", + "label": f"Local: {local_model}", + "provider": "local", + "model": local_model, + "cloud": False, + } + ) + return opts + + +def select_model_option(app: Any, key: str) -> None: + """Apply a picker choice (by its precomputed key) to the active conversation.""" + if key == "local": + app.state.use_cloud_models = False + # Keep the settings dialog's Cloud/Local tab in step with the picker. + app.state.tab_index = 1 + else: + # key form: "cloud::" + parts = key.split(":", 2) + if len(parts) != 3: + return + _, provider, model = parts + app.state.use_cloud_models = True + app.state.provider = provider + app.state.model = model + app.state.tab_index = 0 + + # Recompute temperature support through the same path the settings dialog + # uses, so the picker and the dialog can't disagree. This also covers the + # local branch, where no @change("model") hook fires. + from . import configuration + + configuration.on_model_change(app) + + # Persist immediately so the choice sticks to this conversation. + from . import sessions as sessions_mod + + sessions_mod.capture_current_session(app) diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index c813b01..5165095 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -24,6 +24,7 @@ "id", "title", "created", "updated", "pinned", "messages", "code_history", "code_history_labels", "code_history_pos", "checkpoints", "console_log", "console_lines", "console_level", + "model_config", ) @@ -50,6 +51,7 @@ def _new_session() -> dict: "console_log": [], "console_lines": [], "console_level": "out", + "model_config": {}, } @@ -137,6 +139,9 @@ def capture_current_session(app: Any) -> None: 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") + from .model_config import snapshot_model_config + + sess["model_config"] = snapshot_model_config(app) _maybe_title(app, sess) _persist_session(sess) @@ -235,6 +240,13 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None: 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") + # Restore this conversation's model choice. An empty config (new session or + # a file saved before this feature) is a no-op, so the conversation keeps + # whatever model is currently active - i.e. new conversations default to the + # last-used model. + from .model_config import apply_model_config + + apply_model_config(app, sess.get("model_config") or {}) # 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 "" @@ -421,12 +433,27 @@ def rename_session(app: Any, session_id: str, title: str) -> None: def delete_session(app: Any, session_id: str) -> None: """Delete a conversation; if it was active, open the next most recent.""" + delete_sessions(app, [session_id]) + + +def delete_sessions(app: Any, session_ids: Any) -> None: + """Delete conversations in one pass. + + Deleting one at a time would make the active-session handoff fire per + conversation, potentially loading a session that is itself about to be + deleted. Everything is removed first, then the active session is resolved + once at the end. + """ sessions = _sessions(app) - if session_id not in sessions: + ids = [sid for sid in (session_ids or []) if sid in sessions] + if not ids: return - was_current = session_id == (getattr(app.state, "current_session_id", "") or "") - del sessions[session_id] - _delete_session_file(session_id) + current = getattr(app.state, "current_session_id", "") or "" + was_current = current in ids + + for sid in ids: + del sessions[sid] + _delete_session_file(sid) if was_current: if sessions: @@ -441,3 +468,21 @@ def delete_session(app: Any, session_id: str) -> None: _update_navigation_state(app) refresh_sessions_list(app) + + +def set_sessions_pinned(app: Any, session_ids: Any, pinned: bool) -> None: + """Pin or unpin conversations explicitly. + + Not a toggle: toggling a mixed selection has no coherent meaning, so the + caller states the target instead. + """ + sessions = _sessions(app) + changed = False + for sid in session_ids or []: + sess = sessions.get(sid) + if sess is not None and bool(sess.get("pinned")) != bool(pinned): + sess["pinned"] = bool(pinned) + _persist_session(sess) + changed = True + if changed: + refresh_sessions_list(app) diff --git a/src/vtk_prompt/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py index 9856321..37704f7 100644 --- a/src/vtk_prompt/rendering/code_executor.py +++ b/src/vtk_prompt/rendering/code_executor.py @@ -3,6 +3,7 @@ import contextlib import io import traceback +from typing import Any import vtk import vtkmodules.all as vtkmodules_all @@ -145,14 +146,17 @@ def execute_vtk_code( ] renderer_factory = _InjectedRendererFactory(renderer) for mod in patched_modules: - mod.vtkRenderWindow = _NoOpRenderWindow # type: ignore[assignment,misc] - mod.vtkRenderWindowInteractor = _NoOpInteractor # type: ignore[assignment,misc] - mod.vtkRenderer = renderer_factory # type: ignore[assignment,misc] + # patched_modules mixes an Any-typed vtk import with real module + # types, so mypy widens mod to "Any | Module" and rejects every + # attribute write. Rebinding through Any keeps the monkeypatch + # untyped, which setattr would also do but bugbear forbids (B010). + target: Any = mod + target.vtkRenderWindow = _NoOpRenderWindow + target.vtkRenderWindowInteractor = _NoOpInteractor + target.vtkRenderer = renderer_factory out_buf, err_buf = io.StringIO(), io.StringIO() try: - with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr( - err_buf - ): + with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf): exec(code_segment, exec_globals) # Reset camera and render @@ -163,9 +167,10 @@ def execute_vtk_code( logger.warning("Render error: %s", render_error) finally: for mod, real_window_cls, real_interactor_cls, real_renderer_cls in originals: - mod.vtkRenderWindow = real_window_cls # type: ignore[assignment,misc] - mod.vtkRenderWindowInteractor = real_interactor_cls # type: ignore[assignment,misc] - mod.vtkRenderer = real_renderer_cls # type: ignore[assignment,misc] + restore: Any = mod + restore.vtkRenderWindow = real_window_cls + restore.vtkRenderWindowInteractor = real_interactor_cls + restore.vtkRenderer = real_renderer_cls _last_stdout, _last_stderr = out_buf.getvalue(), err_buf.getvalue() return True, None, None diff --git a/src/vtk_prompt/state/config_state.py b/src/vtk_prompt/state/config_state.py index e7fd6f4..51f31d6 100644 --- a/src/vtk_prompt/state/config_state.py +++ b/src/vtk_prompt/state/config_state.py @@ -9,6 +9,21 @@ from ..provider_utils import DEFAULT_MODEL +# Conventional environment variable per cloud provider, used when the active +# conversation carries no token of its own. GEMINI_API_KEY and GOOGLE_API_KEY +# are both in circulation for Gemini, so the first is tried and the second is +# handled as a fallback below. +_PROVIDER_ENV_VARS = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", + "nim": "NVIDIA_API_KEY", +} +_PROVIDER_ENV_FALLBACKS = { + "gemini": "GOOGLE_API_KEY", + "nim": "NIM_API_KEY", +} + def get_api_key(app: Any) -> str | None: """Get API key from state. @@ -17,12 +32,30 @@ def get_api_key(app: Any) -> str | None: endpoint (Ollama, LM Studio, ...) that ignores auth, but the OpenAI client still refuses to construct with an empty key, so in local mode we fall back to a placeholder. A real key typed into the field overrides it. + + The token is per-conversation, so a conversation that has none falls back to + the provider's environment variable. Startup only seeds ``OPENAI_API_KEY`` + into state, which left the other providers with no path at all short of + typing the key in by hand. """ api_token = getattr(app.state, "api_token", "") if api_token and api_token.strip(): return api_token.strip() if not app.state.use_cloud_models: return "sk-no-key-required" + + import os + + provider = getattr(app.state, "provider", "") + for var in ( + _PROVIDER_ENV_VARS.get(provider), + _PROVIDER_ENV_FALLBACKS.get(provider), + ): + if not var: + continue + from_env = os.environ.get(var, "").strip() + if from_env: + return from_env return None diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index fb487ae..1c7715c 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -87,6 +87,10 @@ def initialize_state(app: Any) -> None: # Sessions: multiple conversations the user can switch between. app.state.current_session_id = "" # active session id app.state.sessions_list = [] # drawer-visible [{id,title,pinned,active}] + # Multi-select in the Recents drawer, for bulk pin/export/delete. The bulk + # action bar appears once anything is checked, so no explicit mode is needed. + app.state.selected_session_ids = [] + app.state.bulk_delete_dialog = False app.state.rename_dialog = False app.state.rename_text = "" app.state.rename_target_id = "" @@ -111,9 +115,19 @@ def initialize_state(app: Any) -> None: app.state.provider = DEFAULT_PROVIDER app.state.model = DEFAULT_MODEL app.state.temperature_supported = True + # Holds the user's temperature while a model that ignores temperature is + # selected, so switching back restores it instead of leaving the clamped + # value behind. Empty string means "nothing stashed" - not None, which + # apply_model_config skips, which would leak one conversation's stash into + # another. + app.state.temperature_pref = "" # Initialize with supported providers and fallback models app.state.available_providers = get_supported_providers() app.state.available_models = get_available_models() + # Flat, display-ready model list for the per-conversation toolbar picker. + from ..controllers.model_config import build_model_options + + app.state.model_options = build_model_options(app) # Load component defaults and sync UI state _load_component_defaults(app) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index b5cee70..4d1a4e8 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -14,6 +14,9 @@ from ..langs import PYTHON_TEXTMATE +# Section heading inside the model-settings menu. +_MENU_LABEL = "text-caption text-medium-emphasis text-uppercase mb-2" + def build_content(layout: Any, app: Any) -> None: """Build the main content area with code panels and VTK viewer.""" @@ -205,31 +208,202 @@ def build_content(layout: Any, app: Any) -> None: with vuetify.VCard(classes="h-25 mt-2"): with vuetify.VCardText(classes="h-100"): with html.Div(classes="d-flex"): - # Cloud models chip - vuetify.VChip( - "☁️ {{ provider }}/{{ model }}", - small=True, - color="blue", - text_color="white", - label=True, - classes="mb-2", - v_show="use_cloud_models", - ) - # Local models chip - vuetify.VChip( - ( - "🏠 " - "{{ local_base_url.replace('http://', '')" - ".replace('https://', '') }}/" - "{{ local_model }}" - ), - small=True, - color="green", - text_color="white", - label=True, - classes="mb-2", - v_show="!use_cloud_models", - ) + # Per-conversation model picker (Claude-style): + # the current model shows as a clickable chip + # and the menu lists selectable models. Its + # settings live behind the adjacent "..." + # button rather than inline here, so the list + # stays a list. Both are stored on the + # conversation, and new conversations inherit + # the last-used values. + with vuetify.VMenu(): + with vuetify.Template( + v_slot_activator="{ props }" + ): + # Cloud models chip (activator) + vuetify.VChip( + "☁️ {{ provider }}/{{ model }}", + v_bind="props", + small=True, + color="blue", + text_color="white", + label=True, + classes="mb-2", + append_icon="mdi-menu-down", + v_show="use_cloud_models", + ) + # Local models chip (activator) + vuetify.VChip( + ( + "🏠 " + "{{ local_base_url" + ".replace('http://', '')" + ".replace('https://', '') }}/" + "{{ local_model }}" + ), + v_bind="props", + small=True, + color="green", + text_color="white", + label=True, + classes="mb-2", + append_icon="mdi-menu-down", + v_show="!use_cloud_models", + ) + with vuetify.VCard(): + # Cap the list so a long provider roster + # scrolls in its own box rather than + # running off the viewport. + with vuetify.VList( + density="compact", + style=( + "max-height: 40vh;" + " overflow-y: auto;" + ), + ): + with vuetify.VListItem( + v_for="opt in model_options", + key="opt.key", + click=( + app.ctrl.select_model, + "[opt.key]", + ), + title=("opt.label",), + ): + pass + # This conversation's model settings, behind a + # "..." beside the picker. Separate menu on + # purpose: the picker stays a plain list, and + # these fields need a menu that does not close + # on every click inside it. + with vuetify.VMenu( + v_model=("model_settings_open", False), + close_on_content_click=False, + min_width="320", + ): + with vuetify.Template( + v_slot_activator="{ props }" + ): + with vuetify.VBtn( + v_bind="props", + icon=True, + variant="text", + size="small", + classes="mb-2 ml-1", + title="Model settings", + ): + # Sliders, not a cog: the toolbar + # cog is global settings and + # mdi-dots-vertical already means + # "overflow menu" in the history + # list, so both would read as + # something this button is not. + vuetify.VIcon("mdi-tune-variant") + with vuetify.VCard(): + # Endpoint and credentials for the + # selected model. Cloud needs only a + # token; local needs the URL and the + # model name its server exposes. + with html.Div(classes="px-4 py-3"): + html.Div( + "Connection", + classes=_MENU_LABEL, + ) + vuetify.VTextField( + label="API token", + v_model="api_token", + type="password", + placeholder="Enter your API token", + density="compact", + variant="outlined", + hide_details=True, + error=("!api_token", False), + v_show="use_cloud_models", + ) + with html.Div( + v_show="!use_cloud_models" + ): + vuetify.VTextField( + label="Base URL", + v_model="local_base_url", + density="compact", + variant="outlined", + hide_details=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Model name", + v_model="local_model", + density="compact", + variant="outlined", + hide_details=True, + classes="mb-3", + ) + vuetify.VTextField( + label="API token", + v_model="api_token", + type="password", + density="compact", + variant="outlined", + hide_details=True, + ) + vuetify.VDivider() + # Generation knobs: per-conversation for + # the same reason the model is. + with html.Div(classes="px-4 py-3"): + html.Div( + "Generation", + classes=_MENU_LABEL, + ) + with html.Div( + classes=( + "d-flex align-center" + " justify-space-between" + ) + ): + html.Span( + "Temperature", + classes="text-body-2", + ) + html.Span( + "{{ temperature }}", + classes=( + "text-body-2" + " text-medium-emphasis" + ), + ) + vuetify.VSlider( + v_model="temperature", + min=0.0, + max=1.0, + step=0.1, + color="primary", + hide_details=True, + density="compact", + disabled=( + "!temperature_supported", + ), + classes="mb-3", + ) + vuetify.VTextField( + label="Max tokens", + v_model="max_tokens", + type="number", + density="compact", + variant="outlined", + hide_details=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Retry attempts", + v_model="retry_attempts", + type="number", + min=1, + max=5, + density="compact", + variant="outlined", + hide_details=True, + ) vuetify.VSpacer() # API token warning chip vuetify.VChip( diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 55563f8..a880fa0 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -50,6 +50,101 @@ def _header(app: Any) -> None: ) +def _selection_bar(app: Any) -> None: + """Bulk actions for the current selection, shown once anything is checked. + + Pin and Unpin are separate rather than one toggle: a mixed selection has no + sensible thing to toggle to. Rename is absent by nature - there is no useful + way to give many conversations one name. + """ + with html.Div( + v_show="selected_session_ids.length > 0", + classes="d-flex align-center flex-wrap px-4 pb-2", + ): + html.Span( + "{{ selected_session_ids.length }} selected", + classes="text-caption text-medium-emphasis mr-2", + ) + vuetify.VBtn( + "All", + click=app.ctrl.select_all_sessions, + variant="text", + size="x-small", + disabled=( + "selected_session_ids.length === sessions_list.length", + False, + ), + ) + vuetify.VBtn( + "None", + click="selected_session_ids = []", + variant="text", + size="x-small", + disabled=("selected_session_ids.length === 0", True), + ) + vuetify.VSpacer() + # Each is enabled only when it would actually change something: Pin when + # some selected row is unpinned, Unpin when some selected row is pinned. + # `some` over an empty selection is false, so these also cover the + # nothing-selected case without a separate length check. + with vuetify.VTooltip(text="Pin selected", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-pin", + click=(app.ctrl.set_selection_pinned, "[true]"), + variant="text", + density="compact", + size="small", + disabled=( + "!sessions_list.some(" + "s => selected_session_ids.includes(s.id) && !s.pinned)", + True, + ), + v_bind="props", + ) + with vuetify.VTooltip(text="Unpin selected", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-pin-off", + click=(app.ctrl.set_selection_pinned, "[false]"), + variant="text", + density="compact", + size="small", + disabled=( + "!sessions_list.some(" + "s => selected_session_ids.includes(s.id) && s.pinned)", + True, + ), + v_bind="props", + ) + with vuetify.VTooltip(text="Export selected", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-tray-arrow-down", + click=( + "window.trame.utils.vtk_prompt.exportSessions(" + "selected_session_ids, sessions_list)" + ), + variant="text", + density="compact", + size="small", + disabled=("selected_session_ids.length === 0", True), + v_bind="props", + ) + with vuetify.VTooltip(text="Delete selected", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-delete", + click="bulk_delete_dialog = true", + variant="text", + density="compact", + size="small", + color="error", + disabled=("selected_session_ids.length === 0", True), + v_bind="props", + ) + + def _row_menu(app: Any) -> None: with vuetify.VMenu(location="bottom end"): with vuetify.Template(v_slot_activator="{ props }"): @@ -137,10 +232,33 @@ def _dialogs(app: Any) -> None: ) +def _bulk_delete_dialog(app: Any) -> None: + with vuetify.VDialog(v_model=("bulk_delete_dialog", False), max_width="420"): + with vuetify.VCard(): + vuetify.VCardTitle("Delete conversations") + vuetify.VCardText( + "Delete {{ selected_session_ids.length }} conversation" + "{{ selected_session_ids.length === 1 ? '' : 's' }}?" + " This cannot be undone." + ) + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn( + "Cancel", click="bulk_delete_dialog = false", variant="text" + ) + vuetify.VBtn( + "Delete", + click=app.ctrl.confirm_delete_selection, + color="error", + variant="text", + ) + + def build_conversation_history(app: Any) -> None: """Build the Recents drawer: conversations, plus the active one's prompts.""" with vuetify.VCard(classes="w-100", flat=True): _header(app) + _selection_bar(app) with vuetify.VCardText(style="overflow-y: auto;"): vuetify.VAlert( text="No conversations yet. Start by generating some VTK code!", @@ -156,6 +274,37 @@ def build_conversation_history(app: Any) -> None: color="primary", ): with html.Div(classes="d-flex align-center w-100"): + # Always visible rather than revealed on hover: hiding it + # cost discoverability, and `visibility: hidden` drops the + # control out of the tab order entirely, making bulk + # selection keyboard-inaccessible. Sizing is left to + # Vuetify - constraining the width collapses the control; + # the title's min-width:0 is what prevents overlap. + vuetify.VCheckbox( + v_model=("selected_session_ids", []), + value=("s.id",), + density="compact", + hide_details=True, + color="primary", + # Round rather than square: purely the icon, so this + # stays a real checkbox input with role="checkbox" - + # screen readers and keyboard behaviour are + # unchanged, despite round conventionally meaning + # "radio, pick one". + false_icon="mdi-circle-outline", + true_icon="mdi-check-circle", + # font-size sizes the glyph; --v-selection-control-size + # sizes the box (and so the row height). Independent: + # the title is a sibling, so neither touches it. + # Vuetify's default control size is 40px - 32 keeps + # rows close to their original height while the + # smaller glyph does the visual work. + style=( + "font-size: 9px;" + " --v-selection-control-size: 32px;" + ), + classes="mr-1 flex-shrink-0", + ) vuetify.VIcon( "mdi-pin", size="x-small", @@ -167,7 +316,11 @@ def build_conversation_history(app: Any) -> None: "{{ s.title }}", click=(app.ctrl.switch_session, "[s.id]"), classes="flex-grow-1 text-truncate", - style="cursor: pointer;", + # min-width:0 is what makes text-truncate actually + # clip inside a flex row; without it the span keeps + # its full intrinsic width and pushes into whatever + # sits beside it. + style="cursor: pointer; min-width: 0;", ) # This conversation is generating, wherever you are. vuetify.VProgressCircular( @@ -190,3 +343,4 @@ def build_conversation_history(app: Any) -> None: ) _row_menu(app) _dialogs(app) + _bulk_delete_dialog(app) diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index 19d20fc..46ad1f4 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -10,8 +10,6 @@ from trame.widgets import html from trame.widgets import vuetify3 as vuetify -from ...provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER - vuetify.enable_lab() _LABEL = "text-overline text-medium-emphasis d-block mb-1" @@ -34,13 +32,11 @@ def build_settings_dialog(layout: Any, app: Any) -> None: ): vuetify.VTab("Config", value="files") vuetify.VTab("Data", value="data") - vuetify.VTab("Model", value="model") vuetify.VTab("Advanced", value="advanced") vuetify.VDivider() with vuetify.VTabsWindow(v_model=("active_settings_tab", "files")): _config_tab() _data_tab() - _model_tab() _advanced_tab() @@ -85,79 +81,6 @@ def _config_tab() -> None: ) -def _model_tab() -> None: - with vuetify.VTabsWindowItem(value="model"): - with vuetify.VCardText(classes="pa-4"): - with vuetify.VTabs( - v_model=("tab_index", 0), - color="primary", - density="compact", - classes="mb-4", - ): - vuetify.VTab("Cloud", prepend_icon="mdi-cloud-outline") - vuetify.VTab("Local", prepend_icon="mdi-laptop") - with vuetify.VTabsWindow(v_model="tab_index", classes="pt-3"): - with vuetify.VTabsWindowItem(): - vuetify.VSelect( - label="Provider", - v_model=("provider", DEFAULT_PROVIDER), - items=("available_providers", []), - density="compact", - variant="outlined", - classes="mb-3", - ) - vuetify.VSelect( - label="Model", - v_model=("model", DEFAULT_MODEL), - items=("available_models[provider] || []",), - density="compact", - variant="outlined", - classes="mb-3", - ) - vuetify.VTextField( - label="API token", - v_model=("api_token", ""), - placeholder="Enter your API token", - type="password", - density="compact", - variant="outlined", - hint="Required for cloud providers", - persistent_hint=True, - error=("!api_token", False), - ) - with vuetify.VTabsWindowItem(): - vuetify.VTextField( - label="Base URL", - v_model=("local_base_url", "http://localhost:11434/v1"), - placeholder="http://localhost:11434/v1", - density="compact", - variant="outlined", - hint="Ollama, LM Studio, and other OpenAI-compatible servers", - persistent_hint=True, - classes="mb-3", - ) - vuetify.VTextField( - label="Model name", - v_model=("local_model", "devstral"), - placeholder="devstral", - density="compact", - variant="outlined", - hint="Model identifier as served by your endpoint", - persistent_hint=True, - classes="mb-3", - ) - vuetify.VTextField( - label="API token", - v_model=("api_token", "ollama"), - placeholder="ollama", - type="password", - density="compact", - variant="outlined", - hint="Optional for local servers", - persistent_hint=True, - ) - - def _advanced_tab() -> None: with vuetify.VTabsWindowItem(value="advanced"): with vuetify.VCardText(classes="pa-4"): @@ -229,50 +152,13 @@ def _advanced_tab() -> None: hide_details=True, ) - vuetify.VDivider(classes="my-5") - _section("Generation") - with html.Div(classes="d-flex align-center justify-space-between mt-2 mb-1"): - html.Span("Temperature", classes="text-body-2") - html.Span( - "{{ temperature }}", classes="text-body-2 text-medium-emphasis" - ) - vuetify.VSlider( - v_model=("temperature", 0.1), - min=0.0, - max=1.0, - step=0.1, - thumb_label=True, - color="primary", - hide_details=True, - disabled=("!temperature_supported",), - classes="mb-4", - ) - vuetify.VTextField( - label="Max tokens", - v_model=("max_tokens", 1000), - type="number", - density="compact", - variant="outlined", - classes="mb-3", - ) - vuetify.VTextField( - label="Retry attempts", - v_model=("retry_attempts", 3), - type="number", - min=1, - max=5, - density="compact", - variant="outlined", - ) - def _data_tab() -> None: with vuetify.VTabsWindowItem(value="data"): with vuetify.VCardText(classes="pa-4"): _section("Sample data location") html.Div( - "Local VTK data tree used to resolve example datasets by name " - "(e.g. cow.g).", + "Local VTK data tree used to resolve example datasets by name " "(e.g. cow.g).", classes=_DESC, ) vuetify.VTextField( diff --git a/src/vtk_prompt/utils.js b/src/vtk_prompt/utils.js index e019583..0650c4d 100644 --- a/src/vtk_prompt/utils.js +++ b/src/vtk_prompt/utils.js @@ -34,6 +34,20 @@ window.trame.utils.vtk_prompt = { } }, + // One file per conversation, so the result re-imports through the existing + // multi-file import path. Sequential with a small gap: firing N downloads at + // once gets some of them dropped, and Chrome will ask permission for the set. + async exportSessions(ids, sessions) { + const titles = {}; + (sessions || []).forEach((s) => { + titles[s.id] = s.title; + }); + for (const id of ids || []) { + await this.exportSession(id, titles[id] || id); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + }, + async exportConfig() { const text = await window.trame.trigger("save_config"); if (text) this.download("vtk-prompt-config.yaml", text, "text/yaml"); diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index f9d32f7..86594ae 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -184,6 +184,12 @@ def on_tab_change(self, tab_index: int, **_: Any) -> None: def _on_model_change(self, **_: Any) -> None: """Handle model change to update temperature support.""" configuration.on_model_change(self, **_) + # The local entry's label carries the local model name, so editing that + # name in the picker menu has to refresh the list or the chip and the + # menu entry go on showing the old one. + from .controllers import model_config + + self.state.model_options = model_config.build_model_options(self) @controller.set("generate_code") def generate_code(self) -> None: @@ -210,6 +216,15 @@ 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("select_model") + def select_model(self, key: str) -> None: + """Apply a per-conversation model choice from the toolbar picker.""" + from .controllers import model_config + + model_config.select_model_option(self, key) + # Refresh the picker labels (the local entry shows the current model). + self.state.model_options = model_config.build_model_options(self) + @controller.set("undo_code") def undo_code(self) -> None: """Revert the code panel to the previous version and re-render.""" @@ -303,12 +318,47 @@ def remove_uploaded_file(self, name): @change("advanced_settings_open") def _on_settings_open(self, advanced_settings_open, **kwargs): - """Refresh the data lists whenever the settings dialog opens.""" + """Refresh data lists on open; persist install-wide settings on close.""" if advanced_settings_open: from .data import cached_names, uploaded_names self.state.uploaded_data_files = uploaded_names() self.state.cached_data_files = cached_names() + else: + # The dialog now holds only global settings (the per-conversation + # ones moved to the model menu), so closing it is the point to write + # them back to the config file that supplied them at startup. + configuration.persist_global_settings(self) + + @change("model_settings_open") + def _on_model_settings_toggle(self, model_settings_open, **kwargs): + """Fold model-settings edits into the active conversation on close. + + Closing the menu is the moment those edits become final. Capturing here + rather than on every field change keeps one JSON write per adjustment + session, and - unlike a @change on the fields themselves - it cannot + fire mid-restore, since trame flushes those callbacks after + ``load_session`` has already returned. + + The session is pinned on open because trame flushes this callback late: + clicking another conversation while the menu is open closes the menu and + switches sessions in the same cycle, so an unguarded capture would write + this conversation's edits onto the one just switched to. + """ + if model_settings_open: + self._model_settings_session_id = ( + getattr(self.state, "current_session_id", "") or "" + ) + return + opened_for = getattr(self, "_model_settings_session_id", None) + current = getattr(self.state, "current_session_id", "") or "" + if opened_for is not None and opened_for != current: + # Switched away with the menu open; switch_session already captured + # that conversation, so writing now would land on the wrong one. + return + from .controllers import sessions + + sessions.capture_current_session(self) @trigger("clear_data_cache") def clear_data_cache(self): @@ -393,6 +443,23 @@ def confirm_delete_session(self) -> None: sessions.delete_session(self, self.state.delete_target_id) self.state.delete_dialog = False + @controller.set("set_selection_pinned") + def set_selection_pinned(self, pinned: bool) -> None: + """Pin or unpin every selected conversation.""" + sessions.set_sessions_pinned(self, list(self.state.selected_session_ids or []), pinned) + + @controller.set("confirm_delete_selection") + def confirm_delete_selection(self) -> None: + """Delete every selected conversation and clear the selection.""" + sessions.delete_sessions(self, list(self.state.selected_session_ids or [])) + self.state.selected_session_ids = [] + self.state.bulk_delete_dialog = False + + @controller.set("select_all_sessions") + def select_all_sessions(self) -> None: + """Select every conversation currently listed in the drawer.""" + self.state.selected_session_ids = [s["id"] for s in (self.state.sessions_list or [])] + @trigger("save_conversation") def save_conversation(self) -> str: """Save current conversation history as JSON string.""" diff --git a/tests/test_code_history.py b/tests/test_code_history.py index cbf59b4..b96a2b9 100644 --- a/tests/test_code_history.py +++ b/tests/test_code_history.py @@ -9,6 +9,7 @@ def _app(): app = types.SimpleNamespace() app.state = types.SimpleNamespace( code_history=[], + code_history_labels=[], code_history_pos=-1, generated_code="", error_message="", @@ -29,7 +30,7 @@ def test_push_builds_history_and_dedups_head(): assert app.state.code_history_pos == 1 -def test_undo_then_redo_resets_position_and_rerenders(monkeypatch): +def test_undo_then_redo_moves_position_without_rerunning(monkeypatch): rendered = [] monkeypatch.setattr( generation, @@ -51,8 +52,9 @@ def test_undo_then_redo_resets_position_and_rerenders(monkeypatch): generation.redo_code(app) assert (app.state.generated_code, app.state.code_history_pos) == ("v2", 1) - # each successful undo/redo re-renders the restored code - assert rendered == ["v2", "v1", "v2"] + # Stepping through history only moves the editor: the restored code is not + # re-executed, so the user decides when to run it. + assert rendered == [] def test_editing_after_undo_drops_the_redo_tail(): diff --git a/tests/test_completion_chain.py b/tests/test_completion_chain.py new file mode 100644 index 0000000..e8d5596 --- /dev/null +++ b/tests/test_completion_chain.py @@ -0,0 +1,180 @@ +"""Tests for docstring-based resolution through VTK call chains. + +jedi cannot infer through VTK's C-extension methods: it renders the return type +in a signature because it parses the docstring, but it does not use that for +inference. So ``tetra.GetPointIds().SetId`` yielded no hover and no completions +even though ``tetra.GetPointIds`` resolved fine. These cover the fallback that +walks the chain by reading the ``->`` annotations. +""" + +from vtk_prompt.completion import complete_python, hover_python + +TETRA = "import vtk\ntetra = vtk.vtkTetra()\ntetra.GetPointIds().SetId(0, 0)" +ACTOR = "import vtk\na = vtk.vtkActor()\na.GetProperty().SetColor(1, 0, 0)" +DEEP = ( + "import vtk\n" + "p = vtk.vtkPolyData()\n" + "p.GetPointData().GetArray(0).GetNumberOfTuples()" +) + + +def _hover_on(code, token): + """Hover with the cursor inside ``token`` on whichever line holds it.""" + for lineno, text in enumerate(code.splitlines(), 1): + if token in text: + return hover_python(code, lineno, text.index(token) + 2) + raise AssertionError(f"{token!r} not in code") + + +def test_hover_through_one_call(): + info = _hover_on(TETRA, "SetId") + assert info is not None + assert info["name"] == "SetId" + assert any("SetId(" in s for s in info["signatures"]) + assert info["prose"] + + +def test_hover_through_call_on_another_class(): + info = _hover_on(ACTOR, "SetColor") + assert info is not None + assert any("SetColor(" in s for s in info["signatures"]) + + +def test_hover_through_multiple_chained_calls(): + info = _hover_on(DEEP, "GetNumberOfTuples") + assert info is not None + assert info["name"] == "GetNumberOfTuples" + + +def test_hover_payload_shape_matches_jedi_path(): + chained = _hover_on(TETRA, "SetId") + direct = _hover_on("import vtk\nids = vtk.vtkIdList()\nids.SetId(0, 0)", "SetId") + assert direct is not None + assert set(chained) == set(direct) + + +def test_completion_after_call_returns_members(): + code = "import vtk\ntetra = vtk.vtkTetra()\ntetra.GetPointIds()." + labels = [c["label"] for c in complete_python(code, 3, len("tetra.GetPointIds()."))] + assert "SetId" in labels + assert "GetNumberOfIds" in labels + + +def test_completion_payload_shape_after_call(): + code = "import vtk\na = vtk.vtkActor()\na.GetProperty()." + out = complete_python(code, 3, len("a.GetProperty().")) + assert out + assert all({"label", "kind", "detail"} <= set(item) for item in out) + + +def test_direct_object_still_uses_jedi(): + """The fallback must not shadow the path that already worked.""" + code = "import vtk\nc = vtk.vtkConeSource()\nc.SetRad" + labels = [c["label"] for c in complete_python(code, 3, len("c.SetRad"))] + assert "SetRadius" in labels + + +def test_chain_off_a_primitive_resolves_to_nothing(): + """``-> int`` gives nothing to chain from, and must not guess.""" + code = "import vtk\nvtk.vtkTetra().GetNumberOfPoints().Foo" + assert hover_python(code, 2, code.splitlines()[1].index("Foo") + 1) is None + + +def test_unknown_root_is_not_resolved(): + code = "mystery.GetPointIds().SetId(0, 0)" + assert hover_python(code, 1, code.index("SetId") + 2) is None + + +def test_out_of_range_position_returns_none(): + assert hover_python("import vtk\n", 99, 99) is None + assert complete_python("import vtk\n", 99, 99) == [] + + +def test_injected_object_resolves_without_jedi(): + """The reported bug: renderer.AddActor lost its hover in longer scripts. + + jedi's help() costs hundreds of ms warm and seconds cold, so the editor + cancelled the request. Resolution for a VTK receiver must not touch jedi. + """ + import vtk + + from vtk_prompt import completion as c + + c.register_runtime_objects(renderer=vtk.vtkRenderer()) + code = "renderer.AddActor(actor)" + + called = [] + real = c.jedi.Interpreter + + class Spy: + def __init__(self, *a, **k): + called.append(1) + self._inner = real(*a, **k) + + def __getattr__(self, name): + return getattr(self._inner, name) + + c.jedi.Interpreter = Spy + try: + info = hover_python(code, 1, code.index("AddActor") + 2) + finally: + c.jedi.Interpreter = real + + assert info is not None + assert info["name"] == "AddActor" + assert not called, "fast path must not invoke jedi for a VTK receiver" + + +def test_local_vtk_assignment_resolves_without_jedi(): + """`cone = vtk.vtkConeSource()` is the shape generated scripts use.""" + code = "import vtk\ncone = vtk.vtkConeSource()\ncone.SetResolution(20)" + info = _hover_on(code, "SetResolution") + assert info is not None + assert any("SetResolution(" in s for s in info["signatures"]) + + +def test_hover_stays_fast_on_a_realistic_script(): + """Guards the actual failure mode: correct but too slow to be delivered.""" + import time + + code = ( + "import vtk\n" + "cone = vtk.vtkConeSource()\n" + "mapper = vtk.vtkPolyDataMapper()\n" + "mapper.SetInputConnection(cone.GetOutputPort())\n" + "actor = vtk.vtkActor()\n" + "actor.GetProperty().SetColor(0.2, 0.4, 0.9)\n" + ) + line = 6 + col = code.splitlines()[5].index("SetColor") + 2 + + start = time.perf_counter() + info = hover_python(code, line, col) + elapsed = time.perf_counter() - start + + assert info is not None + # The docstring path runs in ~1ms; jedi took seconds cold. A generous + # ceiling still catches a regression back onto the slow path. + assert elapsed < 0.5, f"hover took {elapsed:.2f}s - likely back on jedi" + + +def test_non_vtk_code_still_resolves(): + """The fast path must not shadow jedi for ordinary Python.""" + code = "import os\nos.path.join('a', 'b')" + assert hover_python(code, 2, code.splitlines()[1].index("join") + 2) is not None + assert len(complete_python("import json\njson.", 2, 5)) > 0 + + +def test_module_root_is_not_treated_as_an_instance(): + """`vtk` is in the namespace as a module; type(module) must not be used. + + Were the fast path to claim it, completion would list the *module type's* + attributes instead of VTK's classes. + """ + from vtk_prompt.completion import _live_class, _resolve_receiver + + assert _live_class("vtk") is None + assert _resolve_receiver("import vtk\nvtk.", 2, 4, allow_jedi=False) is None + # jedi still answers it, and prefix-filtered lookups find real classes. + labels = [c["label"] for c in complete_python("import vtk\nvtk.vtkConeS", 2, 12)] + assert "vtkConeSource" in labels