oc_chat
Loading...
Searching...
No Matches
completers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/completers.py
4
5"""
6Context-aware tab-completion for the OC Chat REPL (Phase 2 PR 2.A).
7
8Purpose
9-------
10The :class:`OCChatCompleter` understands the *shape* of partial input
11and picks completions from the appropriate live registry — never
12hardcoded. It is plugged into ``prompt_toolkit.PromptSession`` by
13:class:`~oc_ui.terminal.input.PromptDriver`.
14
15This module imports ``prompt_toolkit`` at top-level — only modules
16that need completion functionality should import it, and they should
17only do so after the optional dependency has been verified to be
18available. The :class:`PromptDriver` handles that gating; tests use
19``importorskip``.
20
21Completion sources (in order of input shape):
22
23================================== =====================================
24Input shape Source
25================================== =====================================
26``/<partial>`` ``oc_chat.core.commands.COMMANDS``
27``/model <p>`` / ``/use <p>`` ``ctx.model_manager.known_models()``
28``/session load <p>`` ``list_sessions()`` names
29``/session switch <p>`` ``list_sessions()`` names
30``/session save <p>`` ``list_sessions()`` names + free
31``/session new <p>`` free (no completion)
32``/context <p>`` / ``/read <p>`` paths via ``PathCompleter``
33``/ui reasoning <p>`` ``(\"show\", \"dim\", \"hide\")``
34``/effort <p>`` ``REASONING_EFFORT_LEVELS``
35``/help``, free text, etc. no completions
36================================== =====================================
37
38Responsibilities
39----------------
40- :class:`OCChatCompleter` — top-level completer that routes the
41 prompt-toolkit ``Document`` / ``CompleteEvent`` to one of the
42 per-shape sub-completers.
43- Per-verb completion logic for ``/model``, ``/session``, ``/read``,
44 ``/context``, ``/ui``, ``/effort``.
45
46Diagnostics
47-----------
48Domain: OC_UI.TERMINAL
49Levels:
50 L2 — lifecycle events
51 L3 — semantic details
52 L4 — deep tracing
53
54Contracts
55---------
56- ``get_completions`` is a generator and must not block.
57- All registry reads happen at call time so completions stay fresh
58 as the user adds models / sessions during a REPL run.
59- Unknown verbs yield no completions rather than raising.
60
61Dependencies
62------------
63- oc_diagnostics (structured debug logging via _dbg)
64- prompt_toolkit.completion (interactive REPL input with completion and history)
65- prompt_toolkit.document (interactive REPL input with completion and history)
66- oc_chat.core.commands (lazy; COMMANDS, COMMAND_HELP and UI_REASONING_VALUES for verb and choice completion)
67- oc_chat.core.config (lazy; REASONING_EFFORT_LEVELS for /effort completion)
68- oc_chat.core.persistence (lazy; list_sessions for session name completion)
69"""
70from __future__ import annotations
71
72from typing import Iterable
73
74from oc_diagnostics import _dbg
75
76from prompt_toolkit.completion import (
77 CompleteEvent, Completer, Completion, PathCompleter,
78)
79from prompt_toolkit.document import Document
80
81
82class OCChatCompleter(Completer):
83 """Tab completion driven by the live :class:`Context`."""
84
85 def __init__(self, ctx) -> None:
86 self._ctx = ctx
87 self._path_completer = PathCompleter(expanduser=True)
88 # Cache list_sessions per call to ``get_completions`` so that
89 # multiple keystrokes in the same prompt invocation don't repeat
90 # the disk walk. Reset by ``_refresh_caches``.
91 self._sessions_cache: list[str] | None = None
92
93 # ── Public Completer API ──────────────────────────────────────────────
94
95 def get_completions(self, document: Document,
96 complete_event: CompleteEvent) -> Iterable[Completion]:
97 func = "get_completions"
98 text = document.text_before_cursor
99 _dbg("OC_UI.TERMINAL", func, f"text={text!r}", 4)
100 # Refresh the per-prompt caches once at the start of each
101 # completion request.
102 self._refresh_caches()
103
104 # Free-text chat (not starting with /) → no completions.
105 if not text.startswith("/"):
106 return
107
108 # Tokenise on spaces to figure out which "slot" we're in.
109 head, _, tail = text.partition(" ")
110 if " " not in text:
111 # Still in the verb itself.
112 yield from self._complete_verb(text)
113 return
114
115 # We're past the first space. Dispatch by verb.
116 verb = head.lower()
117
118 # ``get_word_before_cursor(WORD=False)`` splits on non-alphanumeric
119 # chars, so ``deepseek-v4-pro`` would become just ``v4``. For
120 # model and session names (which may contain hyphens) we use
121 # ``_last_token`` instead. For subcommands and simple choices
122 # (which are plain alphanumeric tokens) ``get_word_before_cursor``
123 # is correct and handles trailing-space → empty-string properly.
124 word_pt = document.get_word_before_cursor(WORD=False)
125
126 if verb in ("/model", "/use"):
127 word = self._last_token(text)
128 yield from self._complete_model(word)
129 elif verb == "/effort":
130 yield from self._complete_choices(word_pt, self._effort_values())
131 elif verb == "/context" or verb == "/read":
132 yield from self._delegate_path_completion(document, complete_event)
133 elif verb == "/session":
134 yield from self._complete_session(text, word_pt, document, complete_event)
135 elif verb == "/ui":
136 yield from self._complete_ui(text, word_pt)
137 # Other verbs: no further completions.
138
139 # ── Helpers ───────────────────────────────────────────────────────────
140
141 @staticmethod
142 def _last_token(text: str) -> str:
143 """Return the last whitespace-delimited token in *text*.
144
145 Used instead of ``get_word_before_cursor(WORD=False)`` for values
146 that may contain hyphens (model names, session names).
147 """
148 return text.split()[-1] if text.split() else ""
149
150 def _refresh_caches(self) -> None:
151 self._sessions_cache = None # lazy — fetched on first use
152
153 def _sessions(self) -> list[str]:
154 func = "_sessions"
155 if self._sessions_cache is None:
156 try:
157 from oc_chat.core.persistence import list_sessions
158 self._sessions_cache = [s["name"] for s in list_sessions()]
159 except Exception as e:
160 # VALIDATION_ERROR — sessions dir absent / unreadable; degrade to empty list
161 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
162 self._sessions_cache = []
163 return self._sessions_cache
164
165 def _verbs(self) -> list[str]:
166 from oc_chat.core.commands import COMMANDS
167 return sorted(COMMANDS.keys())
168
169 def _models(self) -> list[str]:
170 func = "_models"
171 try:
172 return list(self._ctx.model_manager.known_models())
173 except Exception as e:
174 # VALIDATION_ERROR — model_manager not yet initialised; degrade to empty list
175 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
176 return []
177
178 def _effort_values(self) -> tuple[str, ...]:
179 func = "_effort_values"
180 try:
181 from oc_chat.core.config import REASONING_EFFORT_LEVELS
182 return tuple(REASONING_EFFORT_LEVELS)
183 except ImportError as e:
184 # VALIDATION_ERROR — config not importable; fall back to canonical levels
185 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
186 return ("low", "medium", "high")
187
188 def _reasoning_values(self) -> tuple[str, ...]:
189 """Plan 147 D5: lazily import the canonical tuple (same pattern as
190 :meth:`_effort_values` / :meth:`_verbs`) instead of hand-duplicating
191 it — a deferred import avoids the module-load-time cycle a top-level
192 ``from oc_chat.core.commands import ...`` would risk."""
193 func = "_reasoning_values"
194 try:
195 from oc_chat.core.commands import UI_REASONING_VALUES
196 return tuple(UI_REASONING_VALUES)
197 except ImportError as e:
198 # VALIDATION_ERROR — commands not importable; fall back to canonical values
199 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
200 return ("show", "dim", "hide")
201
202 # ── Per-shape completers ──────────────────────────────────────────────
203
204 def _complete_verb(self, partial: str) -> Iterable[Completion]:
205 for verb in self._verbs():
206 if verb.startswith(partial):
207 yield Completion(verb, start_position=-len(partial),
208 display_meta=self._verb_help(verb))
209
210 def _verb_help(self, verb: str) -> str:
211 func = "_verb_help"
212 try:
213 from oc_chat.core.commands import COMMAND_HELP
214 line = COMMAND_HELP.get(verb, "")
215 # COMMAND_HELP entries look like: "/verb args - description"
216 if " - " in line:
217 return line.split(" - ", 1)[1].strip()
218 except (ImportError, KeyError) as e:
219 # VALIDATION_ERROR — commands module/key absent; degrade to no help text
220 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
221 return ""
222
223 def _complete_model(self, word: str) -> Iterable[Completion]:
224 for name in self._models():
225 if name.startswith(word):
226 yield Completion(name, start_position=-len(word))
227
228 def _complete_choices(self, word: str,
229 choices: Iterable[str]) -> Iterable[Completion]:
230 for choice in choices:
231 if choice.startswith(word.lower()):
232 yield Completion(choice, start_position=-len(word))
233
234 def _complete_session(self, text: str, word: str,
235 document: Document, complete_event,
236 ) -> Iterable[Completion]:
237 # Parse: /session <sub> [name]
238 tokens = text.split()
239 if len(tokens) <= 1:
240 # Only the verb so far. Suggest subcommands.
241 yield from self._complete_choices(word, _SESSION_SUBS)
242 return
243 sub = tokens[1].lower() if len(tokens) >= 2 else ""
244 if sub not in _SESSION_SUBS_THAT_TAKE_NAMES:
245 return
246
247 # Determine the partial name to match. If there are more than 2
248 # tokens the user has typed (part of) a session name after the
249 # subcommand — use the last token. Otherwise they've just typed
250 # the subcommand and a space (``/session load ``) so match with
251 # an empty prefix.
252 if len(tokens) > 2:
253 name_word = self._last_token(text)
254 else:
255 name_word = ""
256 for name in self._sessions():
257 if name.startswith(name_word):
258 yield Completion(name, start_position=-len(name_word))
259
260 def _complete_ui(self, text: str, word: str) -> Iterable[Completion]:
261 tokens = text.split()
262 if len(tokens) <= 1:
263 yield from self._complete_choices(word, _UI_SUBS)
264 return
265 sub = tokens[1].lower() if len(tokens) >= 2 else ""
266 if sub == "reasoning":
267 yield from self._complete_choices(word, self._reasoning_values())
268
269 def _delegate_path_completion(self, document: Document, complete_event,
270 ) -> Iterable[Completion]:
271 # Build a sub-Document containing only the path-arg text so the
272 # PathCompleter sees it as if it were the only input. The
273 # original cursor position becomes the cursor at end-of-arg.
274 text = document.text_before_cursor
275 # Position right after the verb + first space.
276 first_space = text.find(" ")
277 if first_space < 0:
278 return
279 arg = text[first_space + 1:]
280 sub_doc = Document(text=arg, cursor_position=len(arg))
281 for c in self._path_completer.get_completions(sub_doc, complete_event):
282 yield c
283
284
285# ── Constants used by the dispatcher ─────────────────────────────────────────
286
287_SESSION_SUBS = (
288 "new", "list", "current", "save", "load", "export", "switch", "clear",
289)
290_SESSION_SUBS_THAT_TAKE_NAMES = ("load", "switch", "save")
291
292_UI_SUBS = ("reasoning",)
Iterable[Completion] _complete_choices(self, str word, Iterable[str] choices)
Iterable[Completion] _complete_ui(self, str text, str word)
Iterable[Completion] _delegate_path_completion(self, Document document, complete_event)
Iterable[Completion] _complete_session(self, str text, str word, Document document, complete_event)
Iterable[Completion] _complete_model(self, str word)
Iterable[Completion] get_completions(self, Document document, CompleteEvent complete_event)
Definition completers.py:96
Iterable[Completion] _complete_verb(self, str partial)