jupyterlab_claude_code_extension 1.2.41 → 1.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
36
36
  - **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
37
37
  - **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
38
38
  - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
39
+ - **Coloured terminal tabs** - each Claude session's colour (set with `/color`, or auto-assigned) tints its terminal tab, so open sessions are easy to tell apart; needs the companion `jupyterlab_colourful_tab_extension` (>= 1.0.19), which supplies the tab-colouring API
39
40
  - **Search** - fuzzy filter toggled by the funnel button next to refresh
40
41
  - **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
41
42
  - **Hover tooltip** with project path, last activity, message count, branch, and session id
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
2
2
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
3
3
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
4
4
  import { ITerminalTracker } from '@jupyterlab/terminal';
5
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
5
6
  import { requestAPI } from './request';
6
7
  import { ClaudeCodeSessionsWidget } from './widget';
7
8
  const PLUGIN_ID = 'jupyterlab_claude_code_extension:plugin';
@@ -15,9 +16,10 @@ const plugin = {
15
16
  ILayoutRestorer,
16
17
  ISettingRegistry,
17
18
  ITerminalTracker,
18
- IDefaultFileBrowser
19
+ IDefaultFileBrowser,
20
+ IColourfulTabs
19
21
  ],
20
- activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser) => {
22
+ activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs) => {
21
23
  const settings = app.serviceManager.serverSettings;
22
24
  let status;
23
25
  try {
@@ -31,7 +33,7 @@ const plugin = {
31
33
  console.info('[jupyterlab_claude_code_extension] `claude` binary not found on PATH; panel disabled.');
32
34
  return;
33
35
  }
34
- const widget = new ClaudeCodeSessionsWidget(app, status.root_dir || '', terminalTracker, fileBrowser);
36
+ const widget = new ClaudeCodeSessionsWidget(app, status.root_dir || '', terminalTracker, fileBrowser, colourfulTabs);
35
37
  // Read the sidebar setting before docking so we add the widget to the
36
38
  // user's preferred side on first paint. Default to right.
37
39
  let currentSidebar = 'right';
package/lib/types.d.ts CHANGED
@@ -14,6 +14,9 @@ export interface ISession {
14
14
  remote_control: boolean;
15
15
  favourite: boolean;
16
16
  extra_sessions: number;
17
+ /** Claude session colour from `/color` or auto-assignment (e.g. "blue"), or
18
+ * null when the session carries none. Drives the terminal tab tint. */
19
+ color: string | null;
17
20
  }
18
21
  export interface ISessionsListResponse {
19
22
  sessions: ISession[];
package/lib/widget.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
3
3
  import { ITerminalTracker } from '@jupyterlab/terminal';
4
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
4
5
  import { Widget } from '@lumino/widgets';
5
6
  import { Message } from '@lumino/messaging';
6
7
  export type PresentationMode = 'name' | 'path';
7
8
  export declare class ClaudeCodeSessionsWidget extends Widget {
8
- constructor(app: JupyterFrontEnd, rootDir: string, terminalTracker?: ITerminalTracker | null, fileBrowser?: IDefaultFileBrowser | null);
9
+ constructor(app: JupyterFrontEnd, rootDir: string, terminalTracker?: ITerminalTracker | null, fileBrowser?: IDefaultFileBrowser | null, colourfulTabs?: IColourfulTabs | null);
9
10
  refresh(): void;
10
11
  /** Choose how rows are labelled: by name (session name, initially the
11
12
  * folder name), or by path. */
@@ -81,6 +82,38 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
81
82
  * first.
82
83
  */
83
84
  private _focusTerminal;
85
+ /** Tint a terminal's dock tab with the colour of the Claude session it runs,
86
+ * delegating to jupyterlab_colourful_tab_extension's `setColour` API so that
87
+ * extension owns the tab CSS and colour vocabulary. A no-op when the
88
+ * colourful-tab extension is not installed (the token is optional). An
89
+ * absent/unknown colour clears the tint. */
90
+ private _applyTerminalColour;
91
+ /** Re-tint EVERY open Claude terminal tab from the freshest session colours,
92
+ * whether the plugin launched it or the user opened it themselves. Walks
93
+ * JupyterLab's terminal tracker (the registry of all open terminals) rather
94
+ * than only the plugin's own launch cache, and re-resolves each terminal's
95
+ * Claude conversation on every pass via the ``terminal-cwd`` probe - so a tab
96
+ * whose terminal has since started (or switched) a Claude conversation is
97
+ * re-tinted correctly rather than pinned to a stale identity. The probe is a
98
+ * few /proc reads per terminal; runs after each fetch (launch refresh + the
99
+ * 30s poll), which is already gated to skip while a context menu is open. */
100
+ private _reconcileTerminalColours;
101
+ /** Resolve a terminal's Claude identity: does it run Claude, which
102
+ * conversation id, and its cwd(s). Panel-launched terminals are already known
103
+ * (their conversation was confirmed at launch), so those skip the backend
104
+ * probe; every other terminal is probed once via ``terminal-cwd``. Returns
105
+ * ``null`` when the terminal has no session name yet or the probe fails. */
106
+ private _interrogateTerminal;
107
+ /** Pick a terminal's tab colour from the session list. When the terminal runs
108
+ * a known conversation (its argv carried a ``--resume`` / ``--session-id``),
109
+ * match on that id ONLY: it takes the colour of the exact conversation, or -
110
+ * when that conversation is not the project's representative row - clears,
111
+ * rather than borrowing a sibling branch's colour. The cwd fallback is used
112
+ * ONLY for a terminal with no conversation id (a bare ``claude`` / ``-c``),
113
+ * matching the project the terminal is cwd'd in and preferring the longest
114
+ * matching path so a nested project wins over its parent. Returns null (clear)
115
+ * when no row matches or the matched row has no colour. */
116
+ private _colourForTerminal;
84
117
  private _findTerminalForCwd;
85
118
  private _showCloseExistingDialog;
86
119
  /** Show a modal with a spinner while the terminal is being launched. The
@@ -204,6 +237,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
204
237
  private readonly _removingPaths;
205
238
  private readonly _terminalTracker;
206
239
  private readonly _fileBrowser;
240
+ private readonly _colourfulTabs;
207
241
  private readonly _terminalsByPath;
208
242
  private readonly _pendingByPath;
209
243
  private readonly _rootDir;
package/lib/widget.js CHANGED
@@ -14,6 +14,21 @@ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
14
14
  const DEFAULT_RECENT_LIMIT = 10;
15
15
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
16
16
  const DEFAULT_PRESENTATION_MODE = 'name';
17
+ // Claude's per-session colour (set by `/color`, or auto-assigned for
18
+ // multi-session use) maps to a jupyterlab_colourful_tab_extension colour id.
19
+ // That extension owns the tab-tint CSS and the setColour API; we only feed it
20
+ // the colour. An absent or unrecognised colour resolves to null (clear tint).
21
+ const CLAUDE_TAB_COLOUR_ID = {
22
+ red: 'rose',
23
+ orange: 'peach',
24
+ yellow: 'lemon',
25
+ green: 'mint',
26
+ blue: 'sky',
27
+ purple: 'lavender'
28
+ };
29
+ function claudeTabColourId(color) {
30
+ return (color && CLAUDE_TAB_COLOUR_ID[color]) || null;
31
+ }
17
32
  const SECTION_LABELS = {
18
33
  favourites: 'Favorites',
19
34
  recent: 'Recent',
@@ -62,7 +77,7 @@ catch (_err) {
62
77
  // ignore
63
78
  }
64
79
  export class ClaudeCodeSessionsWidget extends Widget {
65
- constructor(app, rootDir, terminalTracker = null, fileBrowser = null) {
80
+ constructor(app, rootDir, terminalTracker = null, fileBrowser = null, colourfulTabs = null) {
66
81
  super();
67
82
  this._loadingEl = null;
68
83
  this._refreshBtn = null;
@@ -92,6 +107,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
92
107
  this._rootDir = rootDir.replace(/\/+$/, '');
93
108
  this._terminalTracker = terminalTracker;
94
109
  this._fileBrowser = fileBrowser;
110
+ this._colourfulTabs = colourfulTabs;
95
111
  this.id = 'jupyterlab-claude-code-extension';
96
112
  this.title.icon = claudeIcon;
97
113
  this.title.caption = 'Claude Code Sessions';
@@ -337,6 +353,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
337
353
  const data = await requestAPI('sessions', this._serverSettings, { cache: 'no-store' });
338
354
  this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
339
355
  this._render();
356
+ // Best-effort tab tinting; never let a colour failure break the fetch.
357
+ this._reconcileTerminalColours().catch(() => undefined);
340
358
  }
341
359
  async _toggleFavourite(session) {
342
360
  const next = !session.favourite;
@@ -641,6 +659,105 @@ export class ClaudeCodeSessionsWidget extends Widget {
641
659
  }
642
660
  });
643
661
  }
662
+ /** Tint a terminal's dock tab with the colour of the Claude session it runs,
663
+ * delegating to jupyterlab_colourful_tab_extension's `setColour` API so that
664
+ * extension owns the tab CSS and colour vocabulary. A no-op when the
665
+ * colourful-tab extension is not installed (the token is optional). An
666
+ * absent/unknown colour clears the tint. */
667
+ _applyTerminalColour(widget, color) {
668
+ if (!this._colourfulTabs || !widget || widget.isDisposed) {
669
+ return;
670
+ }
671
+ this._colourfulTabs.setColour(widget, claudeTabColourId(color));
672
+ }
673
+ /** Re-tint EVERY open Claude terminal tab from the freshest session colours,
674
+ * whether the plugin launched it or the user opened it themselves. Walks
675
+ * JupyterLab's terminal tracker (the registry of all open terminals) rather
676
+ * than only the plugin's own launch cache, and re-resolves each terminal's
677
+ * Claude conversation on every pass via the ``terminal-cwd`` probe - so a tab
678
+ * whose terminal has since started (or switched) a Claude conversation is
679
+ * re-tinted correctly rather than pinned to a stale identity. The probe is a
680
+ * few /proc reads per terminal; runs after each fetch (launch refresh + the
681
+ * 30s poll), which is already gated to skip while a context menu is open. */
682
+ async _reconcileTerminalColours() {
683
+ var _a;
684
+ if (!this._colourfulTabs || !this._terminalTracker) {
685
+ return;
686
+ }
687
+ const sessions = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
688
+ const widgets = [];
689
+ this._terminalTracker.forEach((widget) => {
690
+ if (widget && !widget.isDisposed) {
691
+ widgets.push(widget);
692
+ }
693
+ });
694
+ await Promise.all(widgets.map(async (widget) => {
695
+ const info = await this._interrogateTerminal(widget);
696
+ if (!info || !info.hasClaude || widget.isDisposed) {
697
+ return;
698
+ }
699
+ this._applyTerminalColour(widget, this._colourForTerminal(info, sessions));
700
+ }));
701
+ }
702
+ /** Resolve a terminal's Claude identity: does it run Claude, which
703
+ * conversation id, and its cwd(s). Panel-launched terminals are already known
704
+ * (their conversation was confirmed at launch), so those skip the backend
705
+ * probe; every other terminal is probed once via ``terminal-cwd``. Returns
706
+ * ``null`` when the terminal has no session name yet or the probe fails. */
707
+ async _interrogateTerminal(widget) {
708
+ var _a, _b, _c;
709
+ for (const entry of this._terminalsByPath.values()) {
710
+ if (entry.widget === widget && entry.sessionId) {
711
+ return { hasClaude: true, sessionId: entry.sessionId, cwds: [] };
712
+ }
713
+ }
714
+ const sessName = (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.content) === null || _a === void 0 ? void 0 : _a.session) === null || _b === void 0 ? void 0 : _b.name;
715
+ if (typeof sessName !== 'string' || !sessName) {
716
+ return null;
717
+ }
718
+ try {
719
+ const data = await requestAPI(`terminal-cwd/${encodeURIComponent(sessName)}`, this._serverSettings);
720
+ return {
721
+ hasClaude: !!(data === null || data === void 0 ? void 0 : data.has_claude),
722
+ sessionId: (_c = data === null || data === void 0 ? void 0 : data.session_id) !== null && _c !== void 0 ? _c : null,
723
+ cwds: Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : []
724
+ };
725
+ }
726
+ catch (_err) {
727
+ // Backend may 404 for a terminal that vanished between enumeration and
728
+ // probe - treat as unresolved and retry on the next poll.
729
+ return null;
730
+ }
731
+ }
732
+ /** Pick a terminal's tab colour from the session list. When the terminal runs
733
+ * a known conversation (its argv carried a ``--resume`` / ``--session-id``),
734
+ * match on that id ONLY: it takes the colour of the exact conversation, or -
735
+ * when that conversation is not the project's representative row - clears,
736
+ * rather than borrowing a sibling branch's colour. The cwd fallback is used
737
+ * ONLY for a terminal with no conversation id (a bare ``claude`` / ``-c``),
738
+ * matching the project the terminal is cwd'd in and preferring the longest
739
+ * matching path so a nested project wins over its parent. Returns null (clear)
740
+ * when no row matches or the matched row has no colour. */
741
+ _colourForTerminal(info, sessions) {
742
+ var _a, _b;
743
+ if (info.sessionId) {
744
+ const bySession = sessions.find(s => s.session_id === info.sessionId);
745
+ return bySession ? ((_a = bySession.color) !== null && _a !== void 0 ? _a : null) : null;
746
+ }
747
+ let best;
748
+ let bestLen = -1;
749
+ for (const raw of info.cwds) {
750
+ const cwd = raw.replace(/\/+$/, '');
751
+ for (const s of sessions) {
752
+ const p = s.project_path.replace(/\/+$/, '');
753
+ if ((cwd === p || cwd.startsWith(p + '/')) && p.length > bestLen) {
754
+ best = s;
755
+ bestLen = p.length;
756
+ }
757
+ }
758
+ }
759
+ return best ? ((_b = best.color) !== null && _b !== void 0 ? _b : null) : null;
760
+ }
644
761
  async _findTerminalForCwd(projectPath, wantedSessionId) {
645
762
  var _a, _b, _c;
646
763
  if (!this._terminalTracker) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.41",
3
+ "version": "1.2.43",
4
4
  "description": "Browse, resume, and manage your Claude Code CLI sessions from a JupyterLab side panel. One click reactivates the right terminal - no duplicate tabs, live remote-control indicator, and favourites for the projects you keep coming back to.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -64,7 +64,8 @@
64
64
  "@jupyterlab/terminal": "^4.0.0",
65
65
  "@jupyterlab/ui-components": "^4.0.0",
66
66
  "@lumino/commands": "^2.0.0",
67
- "@lumino/widgets": "^2.0.0"
67
+ "@lumino/widgets": "^2.0.0",
68
+ "jupyterlab_colourful_tab_extension": "^1.0.19"
68
69
  },
69
70
  "devDependencies": {
70
71
  "@jupyterlab/builder": "^4.0.0",
@@ -125,7 +126,13 @@
125
126
  },
126
127
  "extension": true,
127
128
  "schemaDir": "schema",
128
- "outputDir": "jupyterlab_claude_code_extension/labextension"
129
+ "outputDir": "jupyterlab_claude_code_extension/labextension",
130
+ "sharedPackages": {
131
+ "jupyterlab_colourful_tab_extension": {
132
+ "bundled": false,
133
+ "singleton": true
134
+ }
135
+ }
129
136
  },
130
137
  "eslintIgnore": [
131
138
  "node_modules",
@@ -23,6 +23,7 @@ const session = (over: Partial<ISession> = {}): ISession => ({
23
23
  remote_control: false,
24
24
  favourite: false,
25
25
  extra_sessions: 0,
26
+ color: null,
26
27
  ...over
27
28
  });
28
29
 
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
8
8
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
9
9
  import { ITerminalTracker } from '@jupyterlab/terminal';
10
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
10
11
 
11
12
  import { requestAPI } from './request';
12
13
  import { IStatusResponse } from './types';
@@ -25,7 +26,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
25
26
  ILayoutRestorer,
26
27
  ISettingRegistry,
27
28
  ITerminalTracker,
28
- IDefaultFileBrowser
29
+ IDefaultFileBrowser,
30
+ IColourfulTabs
29
31
  ],
30
32
  activate: async (
31
33
  app: JupyterFrontEnd,
@@ -33,7 +35,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
33
35
  restorer: ILayoutRestorer | null,
34
36
  settingRegistry: ISettingRegistry | null,
35
37
  terminalTracker: ITerminalTracker | null,
36
- fileBrowser: IDefaultFileBrowser | null
38
+ fileBrowser: IDefaultFileBrowser | null,
39
+ colourfulTabs: IColourfulTabs | null
37
40
  ) => {
38
41
  const settings = app.serviceManager.serverSettings;
39
42
 
@@ -59,7 +62,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
59
62
  app,
60
63
  status.root_dir || '',
61
64
  terminalTracker,
62
- fileBrowser
65
+ fileBrowser,
66
+ colourfulTabs
63
67
  );
64
68
 
65
69
  // Read the sidebar setting before docking so we add the widget to the
package/src/types.ts CHANGED
@@ -14,6 +14,9 @@ export interface ISession {
14
14
  remote_control: boolean;
15
15
  favourite: boolean;
16
16
  extra_sessions: number;
17
+ /** Claude session colour from `/color` or auto-assignment (e.g. "blue"), or
18
+ * null when the session carries none. Drives the terminal tab tint. */
19
+ color: string | null;
17
20
  }
18
21
 
19
22
  export interface ISessionsListResponse {
package/src/widget.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { ServerConnection } from '@jupyterlab/services';
10
10
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
11
11
  import { ITerminalTracker } from '@jupyterlab/terminal';
12
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
12
13
  import { copyIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
13
14
  import { CommandRegistry } from '@lumino/commands';
14
15
  import { UUID } from '@lumino/coreutils';
@@ -54,6 +55,23 @@ export type PresentationMode = 'name' | 'path';
54
55
 
55
56
  const DEFAULT_PRESENTATION_MODE: PresentationMode = 'name';
56
57
 
58
+ // Claude's per-session colour (set by `/color`, or auto-assigned for
59
+ // multi-session use) maps to a jupyterlab_colourful_tab_extension colour id.
60
+ // That extension owns the tab-tint CSS and the setColour API; we only feed it
61
+ // the colour. An absent or unrecognised colour resolves to null (clear tint).
62
+ const CLAUDE_TAB_COLOUR_ID: Record<string, string> = {
63
+ red: 'rose',
64
+ orange: 'peach',
65
+ yellow: 'lemon',
66
+ green: 'mint',
67
+ blue: 'sky',
68
+ purple: 'lavender'
69
+ };
70
+
71
+ function claudeTabColourId(color: string | null | undefined): string | null {
72
+ return (color && CLAUDE_TAB_COLOUR_ID[color]) || null;
73
+ }
74
+
57
75
  const SECTION_LABELS: Record<SectionKey, string> = {
58
76
  favourites: 'Favorites',
59
77
  recent: 'Recent',
@@ -119,7 +137,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
119
137
  app: JupyterFrontEnd,
120
138
  rootDir: string,
121
139
  terminalTracker: ITerminalTracker | null = null,
122
- fileBrowser: IDefaultFileBrowser | null = null
140
+ fileBrowser: IDefaultFileBrowser | null = null,
141
+ colourfulTabs: IColourfulTabs | null = null
123
142
  ) {
124
143
  super();
125
144
  this._app = app;
@@ -127,6 +146,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
127
146
  this._rootDir = rootDir.replace(/\/+$/, '');
128
147
  this._terminalTracker = terminalTracker;
129
148
  this._fileBrowser = fileBrowser;
149
+ this._colourfulTabs = colourfulTabs;
130
150
 
131
151
  this.id = 'jupyterlab-claude-code-extension';
132
152
  this.title.icon = claudeIcon;
@@ -408,6 +428,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
408
428
  );
409
429
  this._sessions = data.sessions ?? [];
410
430
  this._render();
431
+ // Best-effort tab tinting; never let a colour failure break the fetch.
432
+ this._reconcileTerminalColours().catch(() => undefined);
411
433
  }
412
434
 
413
435
  private async _toggleFavourite(session: ISession): Promise<void> {
@@ -762,6 +784,123 @@ export class ClaudeCodeSessionsWidget extends Widget {
762
784
  });
763
785
  }
764
786
 
787
+ /** Tint a terminal's dock tab with the colour of the Claude session it runs,
788
+ * delegating to jupyterlab_colourful_tab_extension's `setColour` API so that
789
+ * extension owns the tab CSS and colour vocabulary. A no-op when the
790
+ * colourful-tab extension is not installed (the token is optional). An
791
+ * absent/unknown colour clears the tint. */
792
+ private _applyTerminalColour(
793
+ widget: any,
794
+ color: string | null | undefined
795
+ ): void {
796
+ if (!this._colourfulTabs || !widget || widget.isDisposed) {
797
+ return;
798
+ }
799
+ this._colourfulTabs.setColour(widget, claudeTabColourId(color));
800
+ }
801
+
802
+ /** Re-tint EVERY open Claude terminal tab from the freshest session colours,
803
+ * whether the plugin launched it or the user opened it themselves. Walks
804
+ * JupyterLab's terminal tracker (the registry of all open terminals) rather
805
+ * than only the plugin's own launch cache, and re-resolves each terminal's
806
+ * Claude conversation on every pass via the ``terminal-cwd`` probe - so a tab
807
+ * whose terminal has since started (or switched) a Claude conversation is
808
+ * re-tinted correctly rather than pinned to a stale identity. The probe is a
809
+ * few /proc reads per terminal; runs after each fetch (launch refresh + the
810
+ * 30s poll), which is already gated to skip while a context menu is open. */
811
+ private async _reconcileTerminalColours(): Promise<void> {
812
+ if (!this._colourfulTabs || !this._terminalTracker) {
813
+ return;
814
+ }
815
+ const sessions = this._sessions ?? [];
816
+ const widgets: any[] = [];
817
+ this._terminalTracker.forEach((widget: any) => {
818
+ if (widget && !widget.isDisposed) {
819
+ widgets.push(widget);
820
+ }
821
+ });
822
+ await Promise.all(
823
+ widgets.map(async widget => {
824
+ const info = await this._interrogateTerminal(widget);
825
+ if (!info || !info.hasClaude || widget.isDisposed) {
826
+ return;
827
+ }
828
+ this._applyTerminalColour(
829
+ widget,
830
+ this._colourForTerminal(info, sessions)
831
+ );
832
+ })
833
+ );
834
+ }
835
+
836
+ /** Resolve a terminal's Claude identity: does it run Claude, which
837
+ * conversation id, and its cwd(s). Panel-launched terminals are already known
838
+ * (their conversation was confirmed at launch), so those skip the backend
839
+ * probe; every other terminal is probed once via ``terminal-cwd``. Returns
840
+ * ``null`` when the terminal has no session name yet or the probe fails. */
841
+ private async _interrogateTerminal(widget: any): Promise<{
842
+ hasClaude: boolean;
843
+ sessionId: string | null;
844
+ cwds: string[];
845
+ } | null> {
846
+ for (const entry of this._terminalsByPath.values()) {
847
+ if (entry.widget === widget && entry.sessionId) {
848
+ return { hasClaude: true, sessionId: entry.sessionId, cwds: [] };
849
+ }
850
+ }
851
+ const sessName: string | undefined = widget?.content?.session?.name;
852
+ if (typeof sessName !== 'string' || !sessName) {
853
+ return null;
854
+ }
855
+ try {
856
+ const data = await requestAPI<ITerminalCwdResponse>(
857
+ `terminal-cwd/${encodeURIComponent(sessName)}`,
858
+ this._serverSettings
859
+ );
860
+ return {
861
+ hasClaude: !!data?.has_claude,
862
+ sessionId: data?.session_id ?? null,
863
+ cwds: Array.isArray(data?.cwds) ? data.cwds : []
864
+ };
865
+ } catch (_err) {
866
+ // Backend may 404 for a terminal that vanished between enumeration and
867
+ // probe - treat as unresolved and retry on the next poll.
868
+ return null;
869
+ }
870
+ }
871
+
872
+ /** Pick a terminal's tab colour from the session list. When the terminal runs
873
+ * a known conversation (its argv carried a ``--resume`` / ``--session-id``),
874
+ * match on that id ONLY: it takes the colour of the exact conversation, or -
875
+ * when that conversation is not the project's representative row - clears,
876
+ * rather than borrowing a sibling branch's colour. The cwd fallback is used
877
+ * ONLY for a terminal with no conversation id (a bare ``claude`` / ``-c``),
878
+ * matching the project the terminal is cwd'd in and preferring the longest
879
+ * matching path so a nested project wins over its parent. Returns null (clear)
880
+ * when no row matches or the matched row has no colour. */
881
+ private _colourForTerminal(
882
+ info: { sessionId: string | null; cwds: string[] },
883
+ sessions: ISession[]
884
+ ): string | null {
885
+ if (info.sessionId) {
886
+ const bySession = sessions.find(s => s.session_id === info.sessionId);
887
+ return bySession ? (bySession.color ?? null) : null;
888
+ }
889
+ let best: ISession | undefined;
890
+ let bestLen = -1;
891
+ for (const raw of info.cwds) {
892
+ const cwd = raw.replace(/\/+$/, '');
893
+ for (const s of sessions) {
894
+ const p = s.project_path.replace(/\/+$/, '');
895
+ if ((cwd === p || cwd.startsWith(p + '/')) && p.length > bestLen) {
896
+ best = s;
897
+ bestLen = p.length;
898
+ }
899
+ }
900
+ }
901
+ return best ? (best.color ?? null) : null;
902
+ }
903
+
765
904
  private async _findTerminalForCwd(
766
905
  projectPath: string,
767
906
  wantedSessionId: string | undefined
@@ -2205,6 +2344,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
2205
2344
  private readonly _removingPaths: Set<string> = new Set();
2206
2345
  private readonly _terminalTracker: ITerminalTracker | null;
2207
2346
  private readonly _fileBrowser: IDefaultFileBrowser | null;
2347
+ private readonly _colourfulTabs: IColourfulTabs | null;
2208
2348
  // Microcache of the most-recent terminal per project, tagged with the
2209
2349
  // conversation it is running so reuse can tell a project's branches apart.
2210
2350
  private readonly _terminalsByPath: Map<