Skip to content

fix(inline): require a live suggestion for Tab, and identify responses per request - #444

Open
pjdoland wants to merge 2 commits into
plmbr:mainfrom
pjdoland:fix/442-stale-inline-completion-accept
Open

pjdoland wants to merge 2 commits into
plmbr:mainfrom
pjdoland:fix/442-stale-inline-completion-accept

Conversation

@pjdoland

Copy link
Copy Markdown
Collaborator

Closes #442

The report

The reporter recorded a short video of typing in a cell and the text not taking. Stepping through it at 15 frames per second:

  • The cell text moves through inp, inpu, inputs, inpu, inpu[, inpu[1, inpu], and the cell finally executes as inpu, producing NameError: name inpu is not defined.
  • The status bar reads Ln 1, Col 5 in every frame across roughly six seconds, while the text on screen changes five times. The cursor column never advances.
  • The caret is drawn before the characters that keep changing, and the trailing text renders in the normal editor style.

Why Tab is involved

JupyterLab applies an accepted inline suggestion by replacing the range from the cursor recorded when the completion was requested up to the cursor as it stands now, then moving the cursor to the end of the inserted text (InlineCompleter.accept in @jupyterlab/completer). Anything typed after the request went out sits inside that replaced range.

The Tab shortcut for inline-completer:accept used the selector .jp-Notebook.jp-mod-editMode .jp-mod-completer-enabled. That class means only that the completer is available in this editor, which is true of every code cell in edit mode. JupyterLab gates its own Tab binding for the same command on .jp-mod-inline-completer-active, the class it sets while a suggestion is actually on screen. This change adopts that guard, so Tab accepts a suggestion when there is one and indents otherwise.

Response identity

The provider also identified its own response by reading _lastRequestInfo, a single field that the next keystroke overwrites. A callback reading that field is asking about whichever request started most recently rather than about itself, so a slow answer could be compared against a newer request. A mismatch then called reject() with no argument, which the completer surfaces as console.warn(undefined).

Request identity is now captured per call, and the decision moved into classifyInlineCompletionResponse so it can be tested without standing up the extension. A superseded or empty request resolves with no items instead of rejecting.

Scope, stated plainly

This narrows when Tab can reach the accept path and removes the shared-state ambiguity around late responses. I was not able to reproduce the exact sequence in the report locally, because doing so needs an inline completion model configured, and with no provider configured there is never a suggestion to accept. So this is a narrowing plus a correctness fix rather than a demonstrated cure, and the reporter has been asked for versions, the configured model, and console output to confirm it.

Two observations from the investigation that are not addressed here:

  • jupyter labextension list reports the extension as incompatible with JupyterLab 4.6 because of the @jupyterlab/launcher range being ~4.2.0. The pin was deliberate, for a build-time Token<ILauncher> type mismatch, but the same field advertises runtime compatibility. The extension does load and work on 4.6 in testing. Worth separating the dev pin from the advertised range in its own change.
  • A one-off TypeError from a CodeMirror decoration height measurement inside dispatchTransactions appeared once and never recurred.

Tests

  • tests/ts/inline-completion-request.test.ts: 7 tests over the predicate, covering ownership, supersede, stream end, and ordering of those checks.
  • tests/test_inline_completion_shortcuts.py: 4 tests over the shipped schema, asserting positively that a guarded notebook Tab binding exists and that no Tab binding accepts on the merely-enabled class.

Verified: pytest 1833 passed, tsc clean, jest 430 passed, eslint and prettier clean. Six mutants all fail a named test, including reverting the selector, deleting the binding, and removing either the ownership or the supersede check. The deletion mutant initially survived, which is why the schema test asserts the guarded binding exists rather than only that no unguarded one does.

…s per request

Two changes on the inline completion path.

The Tab shortcut for inline-completer:accept used the selector
.jp-Notebook.jp-mod-editMode .jp-mod-completer-enabled. That class means
only that the completer is available in this editor, which is true of
every code cell in edit mode. JupyterLab gates its own Tab binding for
the same command on .jp-mod-inline-completer-active, the class it sets
while a suggestion is on screen. This adopts that guard, so Tab accepts a
suggestion when there is one to accept and indents otherwise.

The guard matters because accept() replaces the text between the cursor
recorded when the completion was requested and the cursor as it stands
now, then moves the cursor to the end of what it inserted. Anything typed
in between sits inside that replaced range.

The provider also identified its own response by reading _lastRequestInfo,
a single field that the next keystroke overwrites. A callback reading it
is asking about whichever request started most recently rather than about
itself, so a slow answer could be matched against a newer request, and a
mismatch called reject() with no argument, which the completer logs as a
warning with nothing in it. The request identity is now captured per call
and the decision lives in a small predicate that can be tested directly.

Scope worth stating: this narrows when Tab can reach the accept path and
removes the shared-state ambiguity around late responses. The exact
sequence in the report has not been reproduced locally, since doing so
needs an inline completion model configured.
@pjdoland pjdoland added the bug Something isn't working label Sep 14, 2026
@pjdoland

Copy link
Copy Markdown
Collaborator Author

The check_release failure on this PR is unrelated to the change, and a re-run should clear it. I do not have permission to re-run workflows on this repository, so that needs a maintainer.

The details, in case they are useful. jupyter_builder derives a @jupyterlab/core-meta range of 4.5.x from the legacy @jupyterlab/builder devDependency, and there is no 4.5.x release of core-meta published on npm (13 versions are published and the earliest is 4.6.0-alpha.4). The build therefore depends on the fallback that fetches from the jupyterlab/jupyterlab GitHub repository, and that fallback is what failed here:

RuntimeError: Could not resolve @jupyterlab/core-meta for requested version 4.5.x:
not found on the npm registry or in the jupyterlab/jupyterlab GitHub repository
(HTTP Error 403: rate limit exceeded)

The same job on #440 failed in the same window with TimeoutError: The read operation timed out, and #443 passed check_release within that same hour on the same configuration. This PR does not touch package.json.

A durable fix would be moving the devDependency from @jupyterlab/builder to @jupyter/builder, which is what the build warning itself recommends. That changes how the labextension is built, so it seems better as its own PR with its own verification than folded in here.

The previous check_release run failed on a transient GitHub API rate
limit while resolving @jupyterlab/core-meta, unrelated to this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text not inserted with latest version of JupyterLab

1 participant