Skip to content

fix: ensure terminal API object identity and respect grouping mode for plugin-created terminals#17340

Merged
ndoschek merged 3 commits intomasterfrom
GH-16570
Apr 17, 2026
Merged

fix: ensure terminal API object identity and respect grouping mode for plugin-created terminals#17340
ndoschek merged 3 commits intomasterfrom
GH-16570

Conversation

@ndoschek
Copy link
Copy Markdown
Member

What it does

Fixes #16570

The ms-python extension uses strict instance equality (===) in its terminalCloseHandler to check whether a terminal has been closed. However, Theia wraps terminal objects in a Proxy via createAPIObject, so the object returned to extensions differs from the one fired in terminal events. This causes the equality check to fail, making the "Run Python File" button unresponsive after closing and reopening a terminal.

  • Terminal events (onDidOpenTerminal, onDidCloseTerminal, onDidChangeTerminalState) now fire with the same Proxy-wrapped API object that was returned to extensions
  • The terminals getter and activeTerminal also return the API objects
  • A _terminalApiObjects map in TerminalServiceExtImpl tracks the Proxy wrappers alongside the raw terminal instances
  • Plugin-created terminals are now routed through the terminal manager when terminal.grouping.mode is set to tree, instead of always appearing as separate tabs in the bottom panel

How to test

Terminal identity fix (ms-python):

  1. Open a Python file in the example application
  2. Click "Run Python File" in the editor toolbar
  3. Close the terminal that was opened
  4. Click "Run Python File" again
  5. Verify the terminal opens and runs again (previously it became unresponsive)

Terminal manager tree mode with plugin-created terminals:

  1. Set terminal.grouping.mode to tree in preferences
  2. Click "Run Python File" (or any extension that creates a terminal)
  3. Verify the terminal appears inside the terminal manager tree, not as a separate tab in the bottom panel
  4. Close and re-run to verify it continues working

Task terminals in tree mode:

  1. With terminal.grouping.mode set to tree, run a task (e.g. via Terminal > Run Task)
  2. Verify the task terminal appears on the Tasks page in the terminal manager (not as a regular page)
  3. Verify the task completes normally

Integrated terminal in tree mode:

  1. With terminal.grouping.mode set to tree, create a new terminal via the command palette or Ctrl+`
  2. Verify it appears in the terminal manager tree
  3. Split the terminal and verify the split also appears correctly in the tree
  4. Verify typing and command execution works normally

Tabbed mode (regression check):

  1. Switch terminal.grouping.mode to tabbed
  2. Create a new terminal, run a task, and run a Python file
  3. Verify all terminals appear as separate tabs in the bottom panel as before

Switching modes at runtime:

  1. Start with tabbed mode and create some terminals
  2. Switch to tree mode and verify existing terminals migrate to the manager
  3. Switch back to tabbed and verify terminals migrate back to tabs

Example python file to test:

from datetime import datetime

def main():
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print(f"The current time is: {now}")
    date = datetime.now().strftime("%Y-%m-%d")
    print(f"The current date is: {date}")
    weekday = datetime.now().strftime("%A")
    print(f"The current weekday is: {weekday}")

if __name__ == "__main__":
    main()

Follow-ups

  • The editor/title/run toolbar button uses a hardcoded debug-alt icon instead of dynamically picking up the primary command's icon from the contributing
    extension
  • Localization keys from bundled sub-extensions (e.g. %debugpy.command.debuginterminal.title%) may appear unresolved in the toolbar dropdown

Breaking changes

  • This PR introduces breaking changes and requires careful review. If yes, the breaking changes section in the changelog has been updated.

Attribution

Review checklist

Reminder for reviewers

@github-project-automation github-project-automation bot moved this to Waiting on reviewers in PR Backlog Apr 10, 2026
@ndoschek ndoschek requested a review from eneufeld April 10, 2026 14:12
Copy link
Copy Markdown
Contributor

@eneufeld eneufeld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me overall. Some small things to check though.

const parentTerminal = options.location.parentTerminal;
if (parentTerminal instanceof TerminalExtImpl) {
for (const [k, v] of this._terminals) {
if (v === parentTerminal) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you check whether this path works correctly? parentTerminal is a proxy (from createAPIObject) but v is the raw object see the obtainTerminal method. This might have been there earlier but we should fix this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! You're right, the === comparison would fail when a plugin passes the API proxy object (which is what plugins receive) since _terminals only stores the raw TerminalExtImpl. Fixed by checking _terminalApiObjects first, then falling back to _terminals.

this._pseudoTerminals.set(id, new PseudoTerminal(id, this.proxy, options.pty));
return this.proxy.$createTerminal(id, { name: options.name }, undefined, true);
} else {
return this.proxy.$createTerminal(id, profile.options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to store an instance in the _terminalApiObject map?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this path is fine as-is. $startProfile is an RPC call from the Main side, not the createTerminal path that plugins use directly. It calls this.proxy.$createTerminal(), which later triggers $terminalCreated on the Ext side via RPC, populating _terminals through obtainTerminal. Since no apiObjectWrapper is involved (there is no plugin caller receiving the return value), the raw TerminalExtImpl in _terminals is the API object, and the getApiObject fallback handles it correctly.

get terminals(): TerminalExtImpl[] {
return [...this._terminals.values()];
get terminals(): theia.Terminal[] {
return [...this._terminals.keys()].map(id => this.getApiObject(id)!).filter(t => t !== undefined);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the ! after getApiObject? we filter anyway don't we?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the ! was unnecessary since the .filter() already handles undefined. Removed the ! and added a type predicate to the filter instead: .filter((t): t is theia.Terminal => t !== undefined).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add tests for this file? what and when is returning the correct instance is quite confusing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is unused now

protected registerTerminalListener(): void {
this.commandHandlerDisposables.push(
this.terminalFrontendContribution.onDidCreateTerminal(async terminal => {
if (this.isDebugTerminal(terminal)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we now support all terminals and only ignore the ones for tasks? why this change?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code only handled debug terminals (routing them into the manager). The new listener routes all externally-created terminals except tasks and hidden ones. This broadening is intentional: plugin-created terminals have no special kind property, so filtering by kind would not catch them. Task terminals are excluded because they have their own dedicated "Tasks" page (see #17105). Hidden terminals are excluded by design. Debug terminals and regular terminals should both appear in the tree manager when created externally.

* Set to `true` while {@link createTerminalWidget} is executing, so that external
* listeners can distinguish internally created terminals from external ones (e.g. from plugins).
*/
creatingTerminalInternally = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if createTerminalWidget ever is called concurrently, this simple boolean flag will fail. we could use a counter instead. not sure this is worth doing .

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with a counter. It's a small change and makes the code correct under concurrent calls. The getter still returns a boolean so the external API is unchanged.

@github-project-automation github-project-automation bot moved this from Waiting on reviewers to Waiting on author in PR Backlog Apr 13, 2026
Resolves GH-16570

- Terminal events now fire with the same Proxy object returned to extensions
- This fixes strict equality checks (===) failing in extensions like ms-python
- Added apiObjectWrapper parameter to createTerminal for internal registration
- Updated terminals getter and activeTerminal to return API objects
- Updated $provideTerminalLinks to pass API object to link providers
…nager

Part of GH-16570

- Extend terminal creation listener to intercept externally created
  terminals (e.g. from plugins) and add them to the terminal manager
  when grouping mode is set to 'tree'
- Add creatingTerminalInternally flag to TerminalManagerWidget to
  distinguish internally vs externally created terminals
- Move registerTerminalListener into registerHandlers so it is
  consistently (un)registered with preference changes
- Remove unnecessary non-null assertion in terminals getter
- Fix parentTerminal lookup to check API proxy objects, not just raw terminals
- Remove unused isDebugTerminal method
- Use counter instead of boolean for creatingTerminalInternally flag
- Add terminal-ext.spec.ts with 22 tests for API object identity and events

Contributes to GH-16570
Copy link
Copy Markdown
Contributor

@eneufeld eneufeld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you

@github-project-automation github-project-automation bot moved this from Waiting on author to Needs merge in PR Backlog Apr 16, 2026
@ndoschek ndoschek merged commit e960af2 into master Apr 17, 2026
10 checks passed
@ndoschek ndoschek deleted the GH-16570 branch April 17, 2026 12:16
@github-project-automation github-project-automation bot moved this from Needs merge to Done in PR Backlog Apr 17, 2026
@github-actions github-actions bot added this to the 1.71.0 milestone Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

The "Run Python File" button in the ms-python plugin becomes unresponsive after manually closing the terminal and clicking again.

2 participants