jupyterlab_claude_code_extension 1.2.48 → 1.2.49

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.
@@ -0,0 +1,15 @@
1
+ import type { ISession } from './types';
2
+ export declare function claudeTabColourId(color: string | null | undefined): string | null;
3
+ /** What a terminal is running, as resolved by the `terminal-cwd` probe. */
4
+ export interface ITerminalColourInfo {
5
+ sessionId: string | null;
6
+ color: string | null;
7
+ cwds: string[];
8
+ }
9
+ /** A terminal takes its OWN conversation's colour. Never resolve colour via
10
+ * the project row: a row carries only the representative conversation, which
11
+ * flips to any newer JSONL (a daemon `--fork-session` writes one), so
12
+ * row-matching cleared every non-representative terminal's tint (DEF-11). cwd
13
+ * fallback only when the conversation is unreadable - longest path wins, so a
14
+ * nested project beats its parent. Null clears. */
15
+ export declare function colourForTerminal(info: ITerminalColourInfo, sessions: ISession[]): string | null;
package/lib/colour.js ADDED
@@ -0,0 +1,40 @@
1
+ // Claude's per-session colour (`/color`, or auto-assigned) maps to a
2
+ // jupyterlab_colourful_tab_extension colour id. That extension owns the tab
3
+ // CSS and colour vocabulary; we only feed it the colour. Absent or
4
+ // unrecognised resolves to null (clear tint).
5
+ const CLAUDE_TAB_COLOUR_ID = {
6
+ red: 'rose',
7
+ orange: 'peach',
8
+ yellow: 'lemon',
9
+ green: 'mint',
10
+ blue: 'sky',
11
+ purple: 'lavender'
12
+ };
13
+ export function claudeTabColourId(color) {
14
+ return (color && CLAUDE_TAB_COLOUR_ID[color]) || null;
15
+ }
16
+ /** A terminal takes its OWN conversation's colour. Never resolve colour via
17
+ * the project row: a row carries only the representative conversation, which
18
+ * flips to any newer JSONL (a daemon `--fork-session` writes one), so
19
+ * row-matching cleared every non-representative terminal's tint (DEF-11). cwd
20
+ * fallback only when the conversation is unreadable - longest path wins, so a
21
+ * nested project beats its parent. Null clears. */
22
+ export function colourForTerminal(info, sessions) {
23
+ var _a, _b;
24
+ if (info.sessionId) {
25
+ return (_a = info.color) !== null && _a !== void 0 ? _a : null;
26
+ }
27
+ let best;
28
+ let bestLen = -1;
29
+ for (const raw of info.cwds) {
30
+ const cwd = raw.replace(/\/+$/, '');
31
+ for (const s of sessions) {
32
+ const p = s.project_path.replace(/\/+$/, '');
33
+ if ((cwd === p || cwd.startsWith(p + '/')) && p.length > bestLen) {
34
+ best = s;
35
+ bestLen = p.length;
36
+ }
37
+ }
38
+ }
39
+ return best ? ((_b = best.color) !== null && _b !== void 0 ? _b : null) : null;
40
+ }
package/lib/widget.d.ts CHANGED
@@ -98,22 +98,11 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
98
98
  * few /proc reads per terminal; runs after each fetch (launch refresh + the
99
99
  * 30s poll), which is already gated to skip while a context menu is open. */
100
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. */
101
+ /** Probe a terminal: runs Claude?, which conversation, its colour, its
102
+ * cwd(s). Probes EVERY terminal every pass - the launch cache is not
103
+ * consulted, it pins the launch-time conversation and goes stale on an
104
+ * in-place switch. Null when no session name yet, or the probe fails. */
106
105
  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;
117
106
  /** Find an open terminal running the wanted conversation, or null.
118
107
  *
119
108
  * Matches on the conversation id ALONE. The backend reads each terminal's
package/lib/widget.js CHANGED
@@ -5,6 +5,7 @@ import { CommandRegistry } from '@lumino/commands';
5
5
  import { UUID } from '@lumino/coreutils';
6
6
  import { Menu, Widget } from '@lumino/widgets';
7
7
  import { requestAPI } from './request';
8
+ import { claudeTabColourId, colourForTerminal } from './colour';
8
9
  import { addIcon, branchIcon, switchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
9
10
  const POLL_INTERVAL_MS = 30000;
10
11
  // After a fork is requested, watch for its lazily-written JSONL at this fast
@@ -14,21 +15,8 @@ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
14
15
  const DEFAULT_RECENT_LIMIT = 10;
15
16
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
16
17
  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
- }
18
+ // Colour resolution lives in ./colour - pure, JupyterLab-free, and therefore
19
+ // executable under jest (the tint regressed twice behind a green suite).
32
20
  const SECTION_LABELS = {
33
21
  favourites: 'Favorites',
34
22
  recent: 'Recent',
@@ -696,21 +684,15 @@ export class ClaudeCodeSessionsWidget extends Widget {
696
684
  if (!info || !info.hasClaude || widget.isDisposed) {
697
685
  return;
698
686
  }
699
- this._applyTerminalColour(widget, this._colourForTerminal(info, sessions));
687
+ this._applyTerminalColour(widget, colourForTerminal(info, sessions));
700
688
  }));
701
689
  }
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. */
690
+ /** Probe a terminal: runs Claude?, which conversation, its colour, its
691
+ * cwd(s). Probes EVERY terminal every pass - the launch cache is not
692
+ * consulted, it pins the launch-time conversation and goes stale on an
693
+ * in-place switch. Null when no session name yet, or the probe fails. */
707
694
  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
- }
695
+ var _a, _b, _c, _d;
714
696
  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
697
  if (typeof sessName !== 'string' || !sessName) {
716
698
  return null;
@@ -720,6 +702,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
720
702
  return {
721
703
  hasClaude: !!(data === null || data === void 0 ? void 0 : data.has_claude),
722
704
  sessionId: (_c = data === null || data === void 0 ? void 0 : data.session_id) !== null && _c !== void 0 ? _c : null,
705
+ color: (_d = data === null || data === void 0 ? void 0 : data.color) !== null && _d !== void 0 ? _d : null,
723
706
  cwds: Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : []
724
707
  };
725
708
  }
@@ -729,35 +712,6 @@ export class ClaudeCodeSessionsWidget extends Widget {
729
712
  return null;
730
713
  }
731
714
  }
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
- }
761
715
  /** Find an open terminal running the wanted conversation, or null.
762
716
  *
763
717
  * Matches on the conversation id ALONE. The backend reads each terminal's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.48",
3
+ "version": "1.2.49",
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",
@@ -6,6 +6,9 @@ const fs: { readFileSync: (p: string, enc: string) => string } = require('fs');
6
6
  const path: { join: (...args: string[]) => string } = require('path');
7
7
 
8
8
  import type { ISession } from '../types';
9
+ // Executed, not grepped - src/colour.ts is JupyterLab-free by design so the
10
+ // tint logic can actually be run under jest.
11
+ import { claudeTabColourId, colourForTerminal } from '../colour';
9
12
 
10
13
  const session = (over: Partial<ISession> = {}): ISession => ({
11
14
  project_path: '/p',
@@ -528,6 +531,92 @@ describe('launch spinner dismiss contract', () => {
528
531
  * `claude` / `-c` is identifiable too - reuse is not limited to extension
529
532
  * launches that carry an id in argv.
530
533
  */
534
+ /**
535
+ * Tab colour (DEF-11). A terminal's tint is the colour of the conversation
536
+ * it RUNS, which the backend reads from that conversation's own JSONL.
537
+ * Resolving the tint through the project row is the bug: a row carries only
538
+ * the project's representative conversation, and the representative flips to
539
+ * any newer JSONL - a daemon `--fork-session` worker writes one - which
540
+ * cleared the tint of every terminal not on the current representative.
541
+ */
542
+ describe('terminal tab colour', () => {
543
+ // These EXECUTE the shipped code (src/colour.ts) rather than grepping its
544
+ // text: the tint regressed twice (DEF-10, DEF-11) behind a green suite
545
+ // that ran none of it.
546
+ const rows = [
547
+ session({ project_path: '/w', session_id: 'ws', color: 'blue' }),
548
+ session({ project_path: '/w/proj', session_id: 'rep', color: 'green' })
549
+ ];
550
+
551
+ it('takes the running conversation colour, not the row (DEF-11)', () => {
552
+ // The row's representative is 'rep'/green - a daemon fork that stole the
553
+ // slot. The terminal runs 'mine', whose own colour is orange. Resolving
554
+ // via the rows found no match and CLEARED the tint; it must not.
555
+ expect(
556
+ colourForTerminal(
557
+ { sessionId: 'mine', color: 'orange', cwds: ['/w/proj'] },
558
+ rows
559
+ )
560
+ ).toBe('orange');
561
+ });
562
+
563
+ it('a conversation with no colour of its own clears', () => {
564
+ expect(
565
+ colourForTerminal(
566
+ { sessionId: 'mine', color: null, cwds: ['/w'] },
567
+ rows
568
+ )
569
+ ).toBeNull();
570
+ });
571
+
572
+ it('falls back to cwd only when the conversation is unreadable', () => {
573
+ expect(
574
+ colourForTerminal(
575
+ { sessionId: null, color: null, cwds: ['/w/proj'] },
576
+ rows
577
+ )
578
+ ).toBe('green');
579
+ });
580
+
581
+ it('cwd fallback prefers the nested project over its parent', () => {
582
+ expect(
583
+ colourForTerminal(
584
+ { sessionId: null, color: null, cwds: ['/w/proj/deep'] },
585
+ rows
586
+ )
587
+ ).toBe('green');
588
+ expect(
589
+ colourForTerminal(
590
+ { sessionId: null, color: null, cwds: ['/w/other'] },
591
+ rows
592
+ )
593
+ ).toBe('blue');
594
+ });
595
+
596
+ it('cwd fallback does not match a sibling on a shared prefix', () => {
597
+ // '/w/proj-extra' must not match '/w/proj' - the boundary is a real '/'.
598
+ expect(
599
+ colourForTerminal(
600
+ { sessionId: null, color: null, cwds: ['/w/proj-extra'] },
601
+ [
602
+ session({
603
+ project_path: '/w/proj',
604
+ session_id: 'r',
605
+ color: 'green'
606
+ })
607
+ ]
608
+ )
609
+ ).toBeNull();
610
+ });
611
+
612
+ it('maps Claude colours to tab ids and drops unknown ones', () => {
613
+ expect(claudeTabColourId('orange')).toBe('peach');
614
+ expect(claudeTabColourId('green')).toBe('mint');
615
+ expect(claudeTabColourId('chartreuse')).toBeNull();
616
+ expect(claudeTabColourId(null)).toBeNull();
617
+ });
618
+ });
619
+
531
620
  describe('conversation-aware reuse + open-branch contract', () => {
532
621
  const findTerm = (widgetSrc.match(
533
622
  /private async _findTerminalForSession[\s\S]*?\n \}/
package/src/colour.ts ADDED
@@ -0,0 +1,60 @@
1
+ // Pure colour resolution for Claude terminal tabs. Kept free of JupyterLab
2
+ // imports so it is directly executable under jest - the tint regressed twice
3
+ // (DEF-10, DEF-11) behind a green suite that never ran this logic.
4
+ import type { ISession } from './types';
5
+
6
+ // Claude's per-session colour (`/color`, or auto-assigned) maps to a
7
+ // jupyterlab_colourful_tab_extension colour id. That extension owns the tab
8
+ // CSS and colour vocabulary; we only feed it the colour. Absent or
9
+ // unrecognised resolves to null (clear tint).
10
+ const CLAUDE_TAB_COLOUR_ID: Record<string, string> = {
11
+ red: 'rose',
12
+ orange: 'peach',
13
+ yellow: 'lemon',
14
+ green: 'mint',
15
+ blue: 'sky',
16
+ purple: 'lavender'
17
+ };
18
+
19
+ export function claudeTabColourId(
20
+ color: string | null | undefined
21
+ ): string | null {
22
+ return (color && CLAUDE_TAB_COLOUR_ID[color]) || null;
23
+ }
24
+
25
+ /** What a terminal is running, as resolved by the `terminal-cwd` probe. */
26
+ export interface ITerminalColourInfo {
27
+ // Conversation the terminal runs, or null when it cannot be read.
28
+ sessionId: string | null;
29
+ // That conversation's OWN colour, read from its own JSONL by the backend.
30
+ color: string | null;
31
+ cwds: string[];
32
+ }
33
+
34
+ /** A terminal takes its OWN conversation's colour. Never resolve colour via
35
+ * the project row: a row carries only the representative conversation, which
36
+ * flips to any newer JSONL (a daemon `--fork-session` writes one), so
37
+ * row-matching cleared every non-representative terminal's tint (DEF-11). cwd
38
+ * fallback only when the conversation is unreadable - longest path wins, so a
39
+ * nested project beats its parent. Null clears. */
40
+ export function colourForTerminal(
41
+ info: ITerminalColourInfo,
42
+ sessions: ISession[]
43
+ ): string | null {
44
+ if (info.sessionId) {
45
+ return info.color ?? null;
46
+ }
47
+ let best: ISession | undefined;
48
+ let bestLen = -1;
49
+ for (const raw of info.cwds) {
50
+ const cwd = raw.replace(/\/+$/, '');
51
+ for (const s of sessions) {
52
+ const p = s.project_path.replace(/\/+$/, '');
53
+ if ((cwd === p || cwd.startsWith(p + '/')) && p.length > bestLen) {
54
+ best = s;
55
+ bestLen = p.length;
56
+ }
57
+ }
58
+ }
59
+ return best ? (best.color ?? null) : null;
60
+ }
package/src/widget.ts CHANGED
@@ -17,6 +17,7 @@ import { Menu, Widget } from '@lumino/widgets';
17
17
  import { Message } from '@lumino/messaging';
18
18
 
19
19
  import { requestAPI } from './request';
20
+ import { claudeTabColourId, colourForTerminal } from './colour';
20
21
  import {
21
22
  addIcon,
22
23
  branchIcon,
@@ -55,22 +56,8 @@ export type PresentationMode = 'name' | 'path';
55
56
 
56
57
  const DEFAULT_PRESENTATION_MODE: PresentationMode = 'name';
57
58
 
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
- }
59
+ // Colour resolution lives in ./colour - pure, JupyterLab-free, and therefore
60
+ // executable under jest (the tint regressed twice behind a green suite).
74
61
 
75
62
  const SECTION_LABELS: Record<SectionKey, string> = {
76
63
  favourites: 'Favorites',
@@ -122,6 +109,10 @@ interface ITerminalCwdResponse {
122
109
  // Conversation id the running claude is resuming, or null for a fresh
123
110
  // (non-resumed) session. Lets reuse tell branches of one project apart.
124
111
  session_id?: string | null;
112
+ // Colour of the conversation this terminal runs, from its own JSONL.
113
+ // Authoritative for the tint - the row carries only the representative
114
+ // conversation's colour (DEF-11).
115
+ color?: string | null;
125
116
  }
126
117
 
127
118
  // Drop any pre-v0.6.18 localStorage entries from previous schemes - they
@@ -822,29 +813,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
822
813
  if (!info || !info.hasClaude || widget.isDisposed) {
823
814
  return;
824
815
  }
825
- this._applyTerminalColour(
826
- widget,
827
- this._colourForTerminal(info, sessions)
828
- );
816
+ this._applyTerminalColour(widget, colourForTerminal(info, sessions));
829
817
  })
830
818
  );
831
819
  }
832
820
 
833
- /** Resolve a terminal's Claude identity: does it run Claude, which
834
- * conversation id, and its cwd(s). Panel-launched terminals are already known
835
- * (their conversation was confirmed at launch), so those skip the backend
836
- * probe; every other terminal is probed once via ``terminal-cwd``. Returns
837
- * ``null`` when the terminal has no session name yet or the probe fails. */
821
+ /** Probe a terminal: runs Claude?, which conversation, its colour, its
822
+ * cwd(s). Probes EVERY terminal every pass - the launch cache is not
823
+ * consulted, it pins the launch-time conversation and goes stale on an
824
+ * in-place switch. Null when no session name yet, or the probe fails. */
838
825
  private async _interrogateTerminal(widget: any): Promise<{
839
826
  hasClaude: boolean;
840
827
  sessionId: string | null;
828
+ color: string | null;
841
829
  cwds: string[];
842
830
  } | null> {
843
- for (const entry of this._terminalsByPath.values()) {
844
- if (entry.widget === widget && entry.sessionId) {
845
- return { hasClaude: true, sessionId: entry.sessionId, cwds: [] };
846
- }
847
- }
848
831
  const sessName: string | undefined = widget?.content?.session?.name;
849
832
  if (typeof sessName !== 'string' || !sessName) {
850
833
  return null;
@@ -857,6 +840,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
857
840
  return {
858
841
  hasClaude: !!data?.has_claude,
859
842
  sessionId: data?.session_id ?? null,
843
+ color: data?.color ?? null,
860
844
  cwds: Array.isArray(data?.cwds) ? data.cwds : []
861
845
  };
862
846
  } catch (_err) {
@@ -866,38 +850,6 @@ export class ClaudeCodeSessionsWidget extends Widget {
866
850
  }
867
851
  }
868
852
 
869
- /** Pick a terminal's tab colour from the session list. When the terminal runs
870
- * a known conversation (its argv carried a ``--resume`` / ``--session-id``),
871
- * match on that id ONLY: it takes the colour of the exact conversation, or -
872
- * when that conversation is not the project's representative row - clears,
873
- * rather than borrowing a sibling branch's colour. The cwd fallback is used
874
- * ONLY for a terminal with no conversation id (a bare ``claude`` / ``-c``),
875
- * matching the project the terminal is cwd'd in and preferring the longest
876
- * matching path so a nested project wins over its parent. Returns null (clear)
877
- * when no row matches or the matched row has no colour. */
878
- private _colourForTerminal(
879
- info: { sessionId: string | null; cwds: string[] },
880
- sessions: ISession[]
881
- ): string | null {
882
- if (info.sessionId) {
883
- const bySession = sessions.find(s => s.session_id === info.sessionId);
884
- return bySession ? (bySession.color ?? null) : null;
885
- }
886
- let best: ISession | undefined;
887
- let bestLen = -1;
888
- for (const raw of info.cwds) {
889
- const cwd = raw.replace(/\/+$/, '');
890
- for (const s of sessions) {
891
- const p = s.project_path.replace(/\/+$/, '');
892
- if ((cwd === p || cwd.startsWith(p + '/')) && p.length > bestLen) {
893
- best = s;
894
- bestLen = p.length;
895
- }
896
- }
897
- }
898
- return best ? (best.color ?? null) : null;
899
- }
900
-
901
853
  /** Find an open terminal running the wanted conversation, or null.
902
854
  *
903
855
  * Matches on the conversation id ALONE. The backend reads each terminal's