Format: text/x-python
⬇ Download File (23.2 KB)
   1  import re
   2  import asyncio
   3  import tokenize
   4  from io import StringIO
   5  from typing import Callable, List, Optional, Union, Generator, Tuple, ClassVar, Any
   6  import warnings
   7  
   8  import prompt_toolkit
   9  from prompt_toolkit.buffer import Buffer
  10  from prompt_toolkit.key_binding import KeyPressEvent
  11  from prompt_toolkit.key_binding.bindings import named_commands as nc
  12  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
  13  from prompt_toolkit.document import Document
  14  from prompt_toolkit.history import History
  15  from prompt_toolkit.shortcuts import PromptSession
  16  from prompt_toolkit.layout.processors import (
  17      Processor,
  18      Transformation,
  19      TransformationInput,
  20  )
  21  
  22  from IPython.core.getipython import get_ipython
  23  from IPython.utils.tokenutil import generate_tokens
  24  
  25  from .filters import pass_through
  26  
  27  
  28  def _get_query(document: Document):
  29      return document.lines[document.cursor_position_row]
  30  
  31  
  32  class AppendAutoSuggestionInAnyLine(Processor):
  33      """
  34      Append the auto suggestion to lines other than the last (appending to the
  35      last line is natively supported by the prompt toolkit).
  36  
  37      This has a private `_debug` attribute that can be set to True to display
  38      debug information as virtual suggestion on the end of any line. You can do
  39      so with:
  40  
  41          >>> from IPython.terminal.shortcuts.auto_suggest import AppendAutoSuggestionInAnyLine
  42          >>> AppendAutoSuggestionInAnyLine._debug = True
  43  
  44      """
  45  
  46      _debug: ClassVar[bool] = False
  47  
  48      def __init__(self, style: str = "class:auto-suggestion") -> None:
  49          self.style = style
  50  
  51      def apply_transformation(self, ti: TransformationInput) -> Transformation:
  52          """
  53           Apply transformation to the line that is currently being edited.
  54  
  55           This is a variation of the original implementation in prompt toolkit
  56           that allows to not only append suggestions to any line, but also to show
  57           multi-line suggestions.
  58  
  59           As transformation are applied on a line-by-line basis; we need to trick
  60           a bit, and elide any line that is after the line we are currently
  61           editing, until we run out of completions. We cannot shift the existing
  62           lines
  63  
  64           There are multiple cases to handle:
  65  
  66           The completions ends before the end of the buffer:
  67               We can resume showing the normal line, and say that some code may
  68               be hidden.
  69  
  70          The completions ends at the end of the buffer
  71               We can just say that some code may be hidden.
  72  
  73           And separately:
  74  
  75           The completions ends beyond the end of the buffer
  76               We need to both say that some code may be hidden, and that some
  77               lines are not shown.
  78  
  79          """
  80          last_line_number = ti.document.line_count - 1
  81          is_last_line = ti.lineno == last_line_number
  82  
  83          noop = lambda text: Transformation(
  84              fragments=ti.fragments + [(self.style, " " + text if self._debug else "")]
  85          )
  86          if ti.document.line_count == 1:
  87              return noop("noop:oneline")
  88          if ti.document.cursor_position_row == last_line_number and is_last_line:
  89              # prompt toolkit already appends something; just leave it be
  90              return noop("noop:last line and cursor")
  91  
  92          # first everything before the current line is unchanged.
  93          if ti.lineno < ti.document.cursor_position_row:
  94              return noop("noop:before cursor")
  95  
  96          buffer = ti.buffer_control.buffer
  97          if not buffer.suggestion or not ti.document.is_cursor_at_the_end_of_line:
  98              return noop("noop:not eol")
  99  
 100          delta = ti.lineno - ti.document.cursor_position_row
 101          suggestions = buffer.suggestion.text.splitlines()
 102  
 103          if len(suggestions) == 0:
 104              return noop("noop: no suggestions")
 105  
 106          if prompt_toolkit.VERSION < (3, 0, 49):
 107              if len(suggestions) > 1 and prompt_toolkit.VERSION < (3, 0, 49):
 108                  if ti.lineno == ti.document.cursor_position_row:
 109                      return Transformation(
 110                          fragments=ti.fragments
 111                          + [
 112                              (
 113                                  "red",
 114                                  "(Cannot show multiline suggestion; requires prompt_toolkit > 3.0.49)",
 115                              )
 116                          ]
 117                      )
 118                  else:
 119                      return Transformation(fragments=ti.fragments)
 120              elif len(suggestions) == 1:
 121                  if ti.lineno == ti.document.cursor_position_row:
 122                      return Transformation(
 123                          fragments=ti.fragments + [(self.style, suggestions[0])]
 124                      )
 125              return Transformation(fragments=ti.fragments)
 126  
 127          if delta == 0:
 128              suggestion = suggestions[0]
 129              return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
 130          if is_last_line:
 131              if delta < len(suggestions):
 132                  suggestion = f"… rest of suggestion ({len(suggestions) - delta} lines) and code hidden"
 133                  return Transformation([(self.style, suggestion)])
 134  
 135              n_elided = len(suggestions)
 136              for i in range(len(suggestions)):
 137                  ll = ti.get_line(last_line_number - i)
 138                  el = "".join(l[1] for l in ll).strip()
 139                  if el:
 140                      break
 141                  else:
 142                      n_elided -= 1
 143              if n_elided:
 144                  return Transformation([(self.style, f"… {n_elided} line(s) hidden")])
 145              else:
 146                  return Transformation(
 147                      ti.get_line(last_line_number - len(suggestions) + 1)
 148                      + ([(self.style, "shift-last-line")] if self._debug else [])
 149                  )
 150  
 151          elif delta < len(suggestions):
 152              suggestion = suggestions[delta]
 153              return Transformation([(self.style, suggestion)])
 154          else:
 155              shift = ti.lineno - len(suggestions) + 1
 156              return Transformation(ti.get_line(shift))
 157  
 158  
 159  class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
 160      """
 161      A subclass of AutoSuggestFromHistory that allow navigation to next/previous
 162      suggestion from history. To do so it remembers the current position, but it
 163      state need to carefully be cleared on the right events.
 164      """
 165  
 166      skip_lines: int
 167      _connected_apps: list[PromptSession]
 168  
 169      # handle to the currently running llm task that appends suggestions to the
 170      # current buffer; we keep a handle to it in order to cancel it when there is a cursor movement, or
 171      # another request.
 172      _llm_task: asyncio.Task | None = None
 173  
 174      # This is the instance of the LLM provider from jupyter-ai to which we forward the request
 175      # to generate inline completions.
 176      _llm_provider: Any | None
 177      _llm_prefixer: callable = lambda self, x: "wrong"
 178  
 179      def __init__(self):
 180          super().__init__()
 181          self.skip_lines = 0
 182          self._connected_apps = []
 183          self._llm_provider = None
 184          self._request_number = 0
 185  
 186      def reset_history_position(self, _: Buffer):
 187          self.skip_lines = 0
 188  
 189      def disconnect(self) -> None:
 190          self._cancel_running_llm_task()
 191          for pt_app in self._connected_apps:
 192              text_insert_event = pt_app.default_buffer.on_text_insert
 193              text_insert_event.remove_handler(self.reset_history_position)
 194  
 195      def connect(self, pt_app: PromptSession):
 196          self._connected_apps.append(pt_app)
 197          # note: `on_text_changed` could be used for a bit different behaviour
 198          # on character deletion (i.e. resetting history position on backspace)
 199          pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
 200          pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
 201  
 202      def get_suggestion(
 203          self, buffer: Buffer, document: Document
 204      ) -> Optional[Suggestion]:
 205          text = _get_query(document)
 206  
 207          if text.strip():
 208              for suggestion, _ in self._find_next_match(
 209                  text, self.skip_lines, buffer.history
 210              ):
 211                  return Suggestion(suggestion)
 212  
 213          return None
 214  
 215      def _dismiss(self, buffer, *args, **kwargs) -> None:
 216          self._cancel_running_llm_task()
 217          buffer.suggestion = None
 218  
 219      def _find_match(
 220          self, text: str, skip_lines: float, history: History, previous: bool
 221      ) -> Generator[Tuple[str, float], None, None]:
 222          """
 223          text : str
 224              Text content to find a match for, the user cursor is most of the
 225              time at the end of this text.
 226          skip_lines : float
 227              number of items to skip in the search, this is used to indicate how
 228              far in the list the user has navigated by pressing up or down.
 229              The float type is used as the base value is +inf
 230          history : History
 231              prompt_toolkit History instance to fetch previous entries from.
 232          previous : bool
 233              Direction of the search, whether we are looking previous match
 234              (True), or next match (False).
 235  
 236          Yields
 237          ------
 238          Tuple with:
 239          str:
 240              current suggestion.
 241          float:
 242              will actually yield only ints, which is passed back via skip_lines,
 243              which may be a +inf (float)
 244  
 245  
 246          """
 247          line_number = -1
 248          for string in reversed(list(history.get_strings())):
 249              for line in reversed(string.splitlines()):
 250                  line_number += 1
 251                  if not previous and line_number < skip_lines:
 252                      continue
 253                  # do not return empty suggestions as these
 254                  # close the auto-suggestion overlay (and are useless)
 255                  if line.startswith(text) and len(line) > len(text):
 256                      yield line[len(text) :], line_number
 257                  if previous and line_number >= skip_lines:
 258                      return
 259  
 260      def _find_next_match(
 261          self, text: str, skip_lines: float, history: History
 262      ) -> Generator[Tuple[str, float], None, None]:
 263          return self._find_match(text, skip_lines, history, previous=False)
 264  
 265      def _find_previous_match(self, text: str, skip_lines: float, history: History):
 266          return reversed(
 267              list(self._find_match(text, skip_lines, history, previous=True))
 268          )
 269  
 270      def up(self, query: str, other_than: str, history: History) -> None:
 271          self._cancel_running_llm_task()
 272          for suggestion, line_number in self._find_next_match(
 273              query, self.skip_lines, history
 274          ):
 275              # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
 276              # we want to switch from 'very.b' to 'very.a' because a) if the
 277              # suggestion equals current text, prompt-toolkit aborts suggesting
 278              # b) user likely would not be interested in 'very' anyways (they
 279              # already typed it).
 280              if query + suggestion != other_than:
 281                  self.skip_lines = line_number
 282                  break
 283          else:
 284              # no matches found, cycle back to beginning
 285              self.skip_lines = 0
 286  
 287      def down(self, query: str, other_than: str, history: History) -> None:
 288          self._cancel_running_llm_task()
 289          for suggestion, line_number in self._find_previous_match(
 290              query, self.skip_lines, history
 291          ):
 292              if query + suggestion != other_than:
 293                  self.skip_lines = line_number
 294                  break
 295          else:
 296              # no matches found, cycle to end
 297              for suggestion, line_number in self._find_previous_match(
 298                  query, float("Inf"), history
 299              ):
 300                  if query + suggestion != other_than:
 301                      self.skip_lines = line_number
 302                      break
 303  
 304      def _cancel_running_llm_task(self) -> None:
 305          """
 306          Try to cancel the currently running llm_task if exists, and set it to None.
 307          """
 308          if self._llm_task is not None:
 309              if self._llm_task.done():
 310                  self._llm_task = None
 311                  return
 312              cancelled = self._llm_task.cancel()
 313              if cancelled:
 314                  self._llm_task = None
 315              if not cancelled:
 316                  warnings.warn(
 317                      "LLM task not cancelled, does your provider support cancellation?"
 318                  )
 319  
 320      async def _trigger_llm(self, buffer) -> None:
 321          """
 322          This will ask the current llm provider a suggestion for the current buffer.
 323  
 324          If there is a currently running llm task, it will cancel it.
 325          """
 326          # we likely want to store the current cursor position, and cancel if the cursor has moved.
 327          try:
 328              import jupyter_ai_magics
 329              import jupyter_ai.completions.models as jai_models
 330          except ModuleNotFoundError:
 331              jai_models = None
 332          if not self._llm_provider:
 333              warnings.warn("No LLM provider found, cannot trigger LLM completions")
 334              return
 335          if jai_models is None:
 336              warnings.warn(
 337                  "LLM Completion requires `jupyter_ai_magics` and `jupyter_ai` to be installed"
 338              )
 339  
 340          self._cancel_running_llm_task()
 341  
 342          async def error_catcher(buffer):
 343              """
 344              This catches and log any errors, as otherwise this is just
 345              lost in the void of the future running task.
 346              """
 347              try:
 348                  await self._trigger_llm_core(buffer)
 349              except Exception as e:
 350                  get_ipython().log.error("error %s", e)
 351                  raise
 352  
 353          # here we need a cancellable task so we can't just await the error caught
 354          self._llm_task = asyncio.create_task(error_catcher(buffer))
 355          await self._llm_task
 356  
 357      async def _trigger_llm_core(self, buffer: Buffer):
 358          """
 359          This is the core of the current llm request.
 360  
 361          Here we build a compatible `InlineCompletionRequest` and ask the llm
 362          provider to stream it's response back to us iteratively setting it as
 363          the suggestion on the current buffer.
 364  
 365          Unlike with JupyterAi, as we do not have multiple cell, the cell id
 366          is always set to `None`.
 367  
 368          We set the prefix to the current cell content, but could also inset the
 369          rest of the history or even just the non-fail history.
 370  
 371          In the same way, we do not have cell id.
 372  
 373          LLM provider may return multiple suggestion stream, but for the time
 374          being we only support one.
 375  
 376          Here we make the assumption that the provider will have
 377          stream_inline_completions, I'm not sure it is the case for all
 378          providers.
 379          """
 380          try:
 381              import jupyter_ai_magics
 382              import jupyter_ai.completions.models as jai_models
 383          except ModuleNotFoundError:
 384              jai_models = None
 385  
 386          hm = buffer.history.shell.history_manager
 387          prefix = self._llm_prefixer(hm)
 388          get_ipython().log.debug("prefix: %s", prefix)
 389  
 390          self._request_number += 1
 391          request_number = self._request_number
 392          request = jai_models.InlineCompletionRequest(
 393              number=request_number,
 394              prefix=prefix + buffer.document.text,
 395              suffix="",
 396              mime="text/x-python",
 397              stream=True,
 398              path=None,
 399              language="python",
 400              cell_id=None,
 401          )
 402  
 403          async for reply_and_chunks in self._llm_provider.stream_inline_completions(
 404              request
 405          ):
 406              if self._request_number != request_number:
 407                  # If a new suggestion was requested, skip processing this one.
 408                  return
 409              if isinstance(reply_and_chunks, jai_models.InlineCompletionReply):
 410                  if len(reply_and_chunks.list.items) > 1:
 411                      raise ValueError(
 412                          "Terminal IPython cannot deal with multiple LLM suggestions at once"
 413                      )
 414                  buffer.suggestion = Suggestion(
 415                      reply_and_chunks.list.items[0].insertText
 416                  )
 417                  buffer.on_suggestion_set.fire()
 418              elif isinstance(reply_and_chunks, jai_models.InlineCompletionStreamChunk):
 419                  buffer.suggestion = Suggestion(reply_and_chunks.response.insertText)
 420                  buffer.on_suggestion_set.fire()
 421          return
 422  
 423  
 424  async def llm_autosuggestion(event: KeyPressEvent):
 425      """
 426      Ask the AutoSuggester from history to delegate to ask an LLM for completion
 427  
 428      This will first make sure that the current buffer have _MIN_LINES (7)
 429      available lines to insert the LLM completion
 430  
 431      Provisional as of 8.32, may change without warnings
 432  
 433      """
 434      _MIN_LINES = 5
 435      provider = get_ipython().auto_suggest
 436      if not isinstance(provider, NavigableAutoSuggestFromHistory):
 437          return
 438      doc = event.current_buffer.document
 439      lines_to_insert = max(0, _MIN_LINES - doc.line_count + doc.cursor_position_row)
 440      for _ in range(lines_to_insert):
 441          event.current_buffer.insert_text("\n", move_cursor=False)
 442  
 443      await provider._trigger_llm(event.current_buffer)
 444  
 445  
 446  def accept_or_jump_to_end(event: KeyPressEvent):
 447      """Apply autosuggestion or jump to end of line."""
 448      buffer = event.current_buffer
 449      d = buffer.document
 450      after_cursor = d.text[d.cursor_position :]
 451      lines = after_cursor.split("\n")
 452      end_of_current_line = lines[0].strip()
 453      suggestion = buffer.suggestion
 454      if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
 455          buffer.insert_text(suggestion.text)
 456      else:
 457          nc.end_of_line(event)
 458  
 459  
 460  def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
 461      """Accept autosuggestion or jump to end of line.
 462  
 463      .. deprecated:: 8.12
 464          Use `accept_or_jump_to_end` instead.
 465      """
 466      return accept_or_jump_to_end(event)
 467  
 468  
 469  def accept(event: KeyPressEvent):
 470      """Accept autosuggestion"""
 471      buffer = event.current_buffer
 472      suggestion = buffer.suggestion
 473      if suggestion:
 474          buffer.insert_text(suggestion.text)
 475      else:
 476          nc.forward_char(event)
 477  
 478  
 479  def discard(event: KeyPressEvent):
 480      """Discard autosuggestion"""
 481      buffer = event.current_buffer
 482      buffer.suggestion = None
 483  
 484  
 485  def accept_word(event: KeyPressEvent):
 486      """Fill partial autosuggestion by word"""
 487      buffer = event.current_buffer
 488      suggestion = buffer.suggestion
 489      if suggestion:
 490          t = re.split(r"(\S+\s+)", suggestion.text)
 491          buffer.insert_text(next((x for x in t if x), ""))
 492      else:
 493          nc.forward_word(event)
 494  
 495  
 496  def accept_character(event: KeyPressEvent):
 497      """Fill partial autosuggestion by character"""
 498      b = event.current_buffer
 499      suggestion = b.suggestion
 500      if suggestion and suggestion.text:
 501          b.insert_text(suggestion.text[0])
 502  
 503  
 504  def accept_and_keep_cursor(event: KeyPressEvent):
 505      """Accept autosuggestion and keep cursor in place"""
 506      buffer = event.current_buffer
 507      old_position = buffer.cursor_position
 508      suggestion = buffer.suggestion
 509      if suggestion:
 510          buffer.insert_text(suggestion.text)
 511          buffer.cursor_position = old_position
 512  
 513  
 514  def accept_and_move_cursor_left(event: KeyPressEvent):
 515      """Accept autosuggestion and move cursor left in place"""
 516      accept_and_keep_cursor(event)
 517      nc.backward_char(event)
 518  
 519  
 520  def _update_hint(buffer: Buffer):
 521      if buffer.auto_suggest:
 522          suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
 523          buffer.suggestion = suggestion
 524  
 525  
 526  def backspace_and_resume_hint(event: KeyPressEvent):
 527      """Resume autosuggestions after deleting last character"""
 528      nc.backward_delete_char(event)
 529      _update_hint(event.current_buffer)
 530  
 531  
 532  def resume_hinting(event: KeyPressEvent):
 533      """Resume autosuggestions"""
 534      pass_through.reply(event)
 535      # Order matters: if update happened first and event reply second, the
 536      # suggestion would be auto-accepted if both actions are bound to same key.
 537      _update_hint(event.current_buffer)
 538  
 539  
 540  def up_and_update_hint(event: KeyPressEvent):
 541      """Go up and update hint"""
 542      current_buffer = event.current_buffer
 543  
 544      current_buffer.auto_up(count=event.arg)
 545      _update_hint(current_buffer)
 546  
 547  
 548  def down_and_update_hint(event: KeyPressEvent):
 549      """Go down and update hint"""
 550      current_buffer = event.current_buffer
 551  
 552      current_buffer.auto_down(count=event.arg)
 553      _update_hint(current_buffer)
 554  
 555  
 556  def accept_token(event: KeyPressEvent):
 557      """Fill partial autosuggestion by token"""
 558      b = event.current_buffer
 559      suggestion = b.suggestion
 560  
 561      if suggestion:
 562          prefix = _get_query(b.document)
 563          text = prefix + suggestion.text
 564  
 565          tokens: List[Optional[str]] = [None, None, None]
 566          substrings = [""]
 567          i = 0
 568  
 569          for token in generate_tokens(StringIO(text).readline):
 570              if token.type == tokenize.NEWLINE:
 571                  index = len(text)
 572              else:
 573                  index = text.index(token[1], len(substrings[-1]))
 574              substrings.append(text[:index])
 575              tokenized_so_far = substrings[-1]
 576              if tokenized_so_far.startswith(prefix):
 577                  if i == 0 and len(tokenized_so_far) > len(prefix):
 578                      tokens[0] = tokenized_so_far[len(prefix) :]
 579                      substrings.append(tokenized_so_far)
 580                      i += 1
 581                  tokens[i] = token[1]
 582                  if i == 2:
 583                      break
 584                  i += 1
 585  
 586          if tokens[0]:
 587              to_insert: str
 588              insert_text = substrings[-2]
 589              if tokens[1] and len(tokens[1]) == 1:
 590                  insert_text = substrings[-1]
 591              to_insert = insert_text[len(prefix) :]
 592              b.insert_text(to_insert)
 593              return
 594  
 595      nc.forward_word(event)
 596  
 597  
 598  Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
 599  
 600  
 601  def _swap_autosuggestion(
 602      buffer: Buffer,
 603      provider: NavigableAutoSuggestFromHistory,
 604      direction_method: Callable,
 605  ):
 606      """
 607      We skip most recent history entry (in either direction) if it equals the
 608      current autosuggestion because if user cycles when auto-suggestion is shown
 609      they most likely want something else than what was suggested (otherwise
 610      they would have accepted the suggestion).
 611      """
 612      suggestion = buffer.suggestion
 613      if not suggestion:
 614          return
 615  
 616      query = _get_query(buffer.document)
 617      current = query + suggestion.text
 618  
 619      direction_method(query=query, other_than=current, history=buffer.history)
 620  
 621      new_suggestion = provider.get_suggestion(buffer, buffer.document)
 622      buffer.suggestion = new_suggestion
 623  
 624  
 625  def swap_autosuggestion_up(event: KeyPressEvent):
 626      """Get next autosuggestion from history."""
 627      shell = get_ipython()
 628      provider = shell.auto_suggest
 629  
 630      if not isinstance(provider, NavigableAutoSuggestFromHistory):
 631          return
 632  
 633      return _swap_autosuggestion(
 634          buffer=event.current_buffer, provider=provider, direction_method=provider.up
 635      )
 636  
 637  
 638  def swap_autosuggestion_down(event: KeyPressEvent):
 639      """Get previous autosuggestion from history."""
 640      shell = get_ipython()
 641      provider = shell.auto_suggest
 642  
 643      if not isinstance(provider, NavigableAutoSuggestFromHistory):
 644          return
 645  
 646      return _swap_autosuggestion(
 647          buffer=event.current_buffer,
 648          provider=provider,
 649          direction_method=provider.down,
 650      )
 651  
 652  
 653  def __getattr__(key):
 654      if key == "accept_in_vi_insert_mode":
 655          warnings.warn(
 656              "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
 657              "renamed to `accept_or_jump_to_end`. Please update your configuration "
 658              "accordingly",
 659              DeprecationWarning,
 660              stacklevel=2,
 661          )
 662          return _deprected_accept_in_vi_insert_mode
 663      raise AttributeError
 664