Skip to content

Repository files navigation

Tabsimile

A runnable IntelliJ plugin that reproduces the Terminal tool window's shape - many renamable session tabs on the header line, controls after the tab strip, and controls right-aligned on that same line - using only public IntelliJ Platform API.

Tab + facsimile. It copies the Terminal's tab UX and nothing else.

Why it exists

Swttch/swttch#301 asked for a header that "functioned basically exactly like the Terminal": one bar instead of two, tabs that can be renamed, a few icon buttons, and a right-floating control that could still show a line of status text.

It was closed with:

That's a great idea, but unfortunately, this is handled natively by JetBrains IDEs, so it cannot be customized via plugins.

That is not the case, and this repo is the demonstration rather than the argument. Clone it, run ./gradlew runIde, open the Tabsimile tool window at the bottom.

What you get when you run it

A tool window named Tabsimile, docked bottom, whose header is one line:

Tabsimile  Session 1 | Chat 2 x | Session 3 x  [+] [v]     Hello!  [=]   - gear/hide -
\__ id __/ \____________ tabs ______________/  \_ tab _/    \___ title ___/ \_ platform _/
   label                                        actions         actions
  • The tool window name sits left of the tabs. It is the platform's own id label, not a component this plugin adds - it appears simply by not setting HIDE_ID_LABEL.
  • Session tabs on the same line. Drag one along the strip to reorder it. Right-click one for Rename Session..., which opens the platform's own in-place rename popup over the tab. Each tab has a close x.
  • + opens another session. It sits after the tabs, so it stays left aligned and simply shifts right as tabs are added.
  • v opens a dropdown of session templates.
  • = stays pinned to the right in every window, and opens a menu of two toggles.
  • Show Greeting makes a Hello! label appear to its left; unticking removes it. Application-wide, in memory only.
  • Show Tool Window Name hides and restores the id label at the far left, live.

Package layout

Twelve small classes, grouped by which half of the header they serve:

dev.simplified.tabsimile           TabsimileToolWindowFactory, TabsimileIcons
dev.simplified.tabsimile.session   SessionManager, SessionPanel, and the three
                                   actions that create or rename tabs
dev.simplified.tabsimile.header    HeaderMenuAction, GreetingLabelAction,
                                   GreetingState, and the two toggles the menu
                                   holds - ToggleGreetingAction and
                                   ToggleIdLabelAction

Icons are SVG files under src/main/resources/icons, drawn for this repo rather than borrowed from AllIcons, so nothing on screen depends on a platform icon constant that may be renamed. A test asserts every icon path named in plugin.xml or loaded by TabsimileIcons resolves to a file that actually ships.

The answer, in one table

Asked for Possible The API
Many tabs in one tool window yes ToolWindow#getContentManager + ContentFactory#createContent + ContentManager#addContent
Tabs renamable by the user yes subclass ToolWindowTabRenameActionBase, add to ToolWindowContextMenu
Per-tab close button yes canCloseContents="true" + Content#setCloseable
Show or hide the tool window name, live yes ToolWindowContentUi.HIDE_ID_LABEL + ToolWindowEx#updateContentUi
Buttons after the tabs yes ToolWindowEx#setTabActions
A dropdown floated right yes ToolWindow#setTitleActions
Text label floated right, toggleable yes setTitleActions + CustomComponentAction
Right controls stay visible with tabs yes ToolWindowContentUi.DONT_HIDE_TOOLBAR_IN_HEADER
An icon on the header line per tab Content#setIcon + ToolWindow.SHOW_CONTENT_ICON
Drag a tab to reorder yes ToolWindowContentUi.ALLOW_DND_FOR_TABS
A plugin icon left of the tabs no HEADER_ICON is @ApiStatus.Internal and unread on this path
Replacing the gear / hide cluster no the platform always appends it last

The Terminal is a plugin, not the platform

This is the load-bearing fact, and it is checkable in any IntelliJ install at plugins/terminal/lib/terminal.jar!/META-INF/plugin.xml:

<toolWindow id="Terminal" anchor="bottom"
            icon="org.jetbrains.plugins.terminal.TerminalIcons.OpenTerminal_13x13"
            factoryClass="org.jetbrains.plugins.terminal.TerminalToolWindowFactory"
            secondary="false" canCloseContents="true" />

org.jetbrains.plugins.terminal is a bundled plugin using the ordinary com.intellij.toolWindow extension point. There is no private registration path. Disassembling TerminalToolWindowManager shows its session tabs come from ContentFactory.getInstance().createContent(...), ContentManager.addContent(...) and Content.setDisplayName(...) - the same public calls SessionManager makes.

The same is already true inside Swttch itself. Its own plugin.xml registers on the same extension point with the same attribute, and its own comment describes the behaviour:

<!-- Sidebar button: opens an editor tab (EDITOR_TAB mode) or hosts chat
     content tabs directly (TOOL_WINDOW mode). canCloseContents enables the
     per-tab close button used in TOOL_WINDOW mode. -->
<toolWindow id="Claude Code" anchor="right" icon="/icons/claudeCode.svg"
            canCloseContents="true"
            factoryClass="...ClaudeCodeToolWindowFactory"/>

ToolWindowHost.kt already calls createContent / addContent and already sets the HideIdLabel client property. What is missing is only the header-action half - setTitleActions and setTabActions appear nowhere in the repository.

Rename is three lines

The part most worth knowing. The in-place rename popup is platform code in a public package, so the whole feature is a subclass with a constructor:

public final class RenameSessionAction extends ToolWindowTabRenameActionBase {
    public RenameSessionAction() {
        super(TabsimileToolWindowFactory.TOOL_WINDOW_ID, "Session name:");
    }
}

registered with:

<action id="Tabsimile.RenameSession" class="...RenameSessionAction" text="Rename Session...">
    <add-to-group group-id="ToolWindowContextMenu" anchor="last"/>
</action>

ToolWindowTabRenameActionBase carries no @ApiStatus annotation. Its default hooks already read Content#getDisplayName and write Content#setDisplayName, and it anchors a balloon with a text field over the tab label under the cursor. The tool window id passed to the constructor is what confines the menu entry to your own tabs. The Terminal's RenameTerminalSessionAction is the same subclass.

Float right

The header is not one undifferentiated bar. Two different methods target two different slots, and only one of them is on ToolWindowEx:

// after the tab strip - the Terminal's "+" and chevron live here
((ToolWindowEx) toolWindow).setTabActions(tabActionGroup);

// right-aligned on the same line, before the platform's gear and hide buttons
toolWindow.setTitleActions(List.of(new GreetingLabelAction(), new HeaderMenuAction()));

setTitleActions and setAdditionalGearActions are declared on the plain ToolWindow interface. The only @ApiStatus.Internal methods on that entire interface are getStripeTitleProvider and getStripeShortTitleProvider.

Because a header slot is an ordinary action toolbar, it renders whatever component a CustomComponentAction hands it - so the right side is not limited to icon buttons. That is what makes the demo's round trip work:

  • HeaderMenuAction is a popup DefaultActionGroup, so it paints as one icon button that opens a menu.
  • Its two entries are both ToggleActions, which the action system renders with a checkmark.
  • The first, ToggleGreetingAction, writes GreetingState - an application-level service, so one toggle applies to every open window - and calls ActivityTracker.getInstance().inc() so toolbars re-run update() immediately rather than on the next unrelated UI event.
  • GreetingLabelAction reads that state in update() and calls setEnabledAndVisible, so a plain text label appears and disappears beside the button.

The greeting state is held in memory and deliberately not persisted - there is no PersistentStateComponent, so it resets with the IDE.

One caveat that matters: in the New UI the right-hand toolbar fades out unless the tool window is hovered or focused. DONT_HIDE_TOOLBAR_IN_HEADER is the documented opt-out and this plugin sets it, which is what keeps the menu button pinned in every window rather than only the focused one.

The name label needs no state at all

The menu's second entry, ToggleIdLabelAction, shows and hides the tool window's own name at the far left. Showing it is the absence of a call: the platform paints that id label unless ToolWindowContentUi.HIDE_ID_LABEL is set on the tool window's component, so nothing here adds a component. Setting it gives the tabs the whole line, which is the shape the Terminal ships.

That it flips live is the part worth knowing. ContentLayout#shouldShowId re-reads the client property on every layout pass rather than caching it at construction, so the property is itself the state - no service, no field - and the only remaining step is telling the header to lay out again:

toolWindow.getComponent().putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, state ? null : "true");
if (toolWindow instanceof ToolWindowEx ex) ex.updateContentUi();

updateContentUi is a public default method on ToolWindowEx, present on 233 and 253 alike. The property is cleared rather than written "false" because the platform tests "true".equals(...), so anything else already reads as showing and clearing leaves nothing stale behind.

The two toggles therefore sit in one menu reaching deliberately different scopes. The greeting is application-wide and backed by a service because nothing in the platform holds that flag; the name label is per tool window and backed by nothing, because the platform already holds it.

What genuinely is not possible

Being straight about the limits is the point of a proof:

  • A plugin icon at the far left of the header. The icon attribute on <toolWindow> paints the stripe button only. ToolWindowContentUi.HEADER_ICON is @ApiStatus.Internal, and it is read only on the Run/Debug SingleContentSupplier path, never by the tab layout - so there is no unsupported route either. Per-tab icons via Content#setIcon are the available substitute, and this plugin uses them.
  • Reordering or removing the gear / hide cluster. ToolWindowHeader always emits your actions followed by the built-in group, so plugin controls can never be the rightmost element.
  • Dragging a tab out into a free-floating window. That helper is internal and operates on whole tool windows rather than contents.

The one deprecation, and why the deprecated name is the right one

Drag-to-reorder is opted into with ToolWindowContentUi.ALLOW_DND_FOR_TABS, which is marked deprecated from 253 in favour of ALLOW_TABS_REORDERING. That reads worse than it is. In 253's static initializer the two names are one object:

ALLOW_DND_FOR_TABS    = Key.create("AllowDragAndDropForTabs");
ALLOW_TABS_REORDERING = ALLOW_DND_FOR_TABS;

The deprecation is a rename, not a change of behaviour, and ToolWindowContentUi.setAllowTabsReordering(toolWindow, true) compiles down to exactly the same putClientProperty. The renamed field is @ApiStatus.Internal and both it and the helper exist only from 253, so the deprecated spelling is the only one of the three that reaches this plugin's 233 floor. PlatformApiSurfaceTest asserts the two fields are still the same object, so the day that stops being true the build says so.

The platform ANDs that property with the registry flag ide.allow.split.and.reorder.in.tool.window, which ships enabled in 233, 243 and 253 alike - verified in each build's misc/registry.properties. That half is a global user preference, so this plugin reads it and never writes it. The alternative, if you would rather not depend on a user-flippable flag, is what the Terminal also ships: explicit Move Tab Left / Move Tab Right actions over ContentManager.

Running and building

./gradlew runIde          # sandbox IDE with the plugin loaded
./gradlew test            # the API-surface pins, no IDE fixture needed
./gradlew verifyPlugin    # Plugin Verifier against IC 2023.3 / 2024.3 / 2025.3
./gradlew buildPlugin     # -> build/distributions/Tabsimile-1.0.0.zip
./gradlew publishAndPackage   # Maven Local + a Central-shaped staging bundle

src/test is deliberately reflective rather than functional. The claim this repo makes is about an API surface, so the test pins that surface: every method, field and constructor named above fails the build if it is renamed, moved or narrowed.

What the verifier says

JetBrains' own Plugin Verifier is the impartial referee here, and it reports:

Plugin dev.simplified.tabsimile:1.0.0 against IC-233.11799.241: Compatible
Plugin dev.simplified.tabsimile:1.0.0 against IC-243.21565.193: Compatible
Plugin dev.simplified.tabsimile:1.0.0 against IU-253.28294.334: Compatible. 1 usage of deprecated API

Deprecated API usages (1):
    Deprecated field ToolWindowContentUi.ALLOW_DND_FOR_TABS is accessed in
    TabsimileToolWindowFactory.configureHeaderLayout(ToolWindow)

Compatible on all three, and the single flagged item is the deliberate one explained above. Worth noting for what is absent: the verifier reports internal-API usage as its own category, and it raises none - so HIDE_ID_LABEL and DONT_HIDE_TOOLBAR_IN_HEADER are not internal API by JetBrains' own tooling.

Provenance

Every claim here was checked against IntelliJ jars on disk - IC-233.11799.241, IC-243.21565.193 and IU-253.28294.334 - by disassembling the platform and the bundled Terminal plugin, not by reading documentation. The working notes, including the bytecode that settles each point, are in notes/toolwindow-tabs-evidence.md.

About

A runnable IntelliJ plugin proving a third-party plugin can build the Terminal's tool window shape: renamable session tabs on the header line, controls after the tab strip, and right-aligned header controls.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages