jupyterlab_ai_code_assistants_extension 1.2.18 → 1.2.27

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.
Files changed (40) hide show
  1. package/README.md +10 -7
  2. package/lib/core/labels.d.ts +18 -0
  3. package/lib/core/labels.js +68 -0
  4. package/lib/core/panel.d.ts +5 -2
  5. package/lib/core/panel.js +55 -29
  6. package/lib/core/popup.d.ts +3 -2
  7. package/lib/core/popup.js +4 -16
  8. package/lib/core/terminals.d.ts +24 -2
  9. package/lib/core/terminals.js +87 -3
  10. package/lib/core/types.d.ts +16 -4
  11. package/lib/providers/claude.js +1 -0
  12. package/lib/providers/codex.js +1 -0
  13. package/lib/providers/deepseek.d.ts +3 -0
  14. package/lib/providers/deepseek.js +63 -0
  15. package/lib/providers/gemini.js +1 -0
  16. package/lib/providers/index.js +8 -1
  17. package/lib/providers/kimi.d.ts +1 -22
  18. package/lib/providers/kimi.js +5 -80
  19. package/package.json +2 -2
  20. package/schema/plugin.json +7 -1
  21. package/src/__tests__/core-neutrality.spec.ts +42 -1
  22. package/src/__tests__/labels.spec.ts +10 -4
  23. package/src/__tests__/launcher.spec.ts +1 -0
  24. package/src/__tests__/panel.spec.ts +172 -0
  25. package/src/__tests__/popup.spec.ts +90 -0
  26. package/src/__tests__/registry.spec.ts +2 -1
  27. package/src/__tests__/tab-colour-capture.spec.ts +1 -0
  28. package/src/__tests__/terminals.spec.ts +266 -0
  29. package/src/core/labels.ts +74 -0
  30. package/src/core/panel.ts +68 -27
  31. package/src/core/popup.ts +7 -20
  32. package/src/core/terminals.ts +100 -4
  33. package/src/core/types.ts +17 -4
  34. package/src/providers/claude.ts +1 -0
  35. package/src/providers/codex.ts +1 -0
  36. package/src/providers/deepseek.ts +68 -0
  37. package/src/providers/gemini.ts +1 -0
  38. package/src/providers/index.ts +8 -1
  39. package/src/providers/kimi.ts +6 -91
  40. package/style/base.css +11 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
9
  [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
10
 
11
- A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi and Gemini. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting. One install replaces the separate `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension` packages and migrates their settings and favourites automatically.
11
+ A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting. One install replaces the separate `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension` packages and migrates their settings and favourites automatically.
12
12
 
13
13
  ![Claude Code Sessions panel](.resources/screenshot.png)
14
14
 
@@ -25,17 +25,17 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
25
25
 
26
26
  ## Features
27
27
 
28
- - **One install, every assistant** - Claude Code, Codex, Kimi and Gemini from a single package, each with its own right-side panel wearing its official mark
28
+ - **One install, every assistant** - Claude Code, Codex, Kimi, Gemini and DeepSeek from a single package, each with its own right-side panel wearing its official mark
29
29
  - **Provider registry** - assistant-specific behaviour lives in one module per assistant behind capability flags; no core file names an assistant, and adding one touches no core file
30
30
  - **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload
31
31
  - **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
32
- - **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated
32
+ - **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated. Exception: a Codex or Kimi conversation started with `+`, and a Codex fork, run without an id until the CLI writes one, so a click on that row meanwhile opens a second terminal
33
33
  - **Launcher tiles** - every enabled assistant has a tile in an "AI Assistants" section of the JupyterLab Launcher (after Other, under the extension's own robot-head icon); a click opens the assistant in the file browser's current folder, resuming the folder's conversation when one exists and starting a new one otherwise, through the companion `jupyterlab_basic_terminal_extension` (installed automatically). Disable an assistant and its tile goes; disable them all and the section goes with them
34
34
  - **Your terminal's `claude -c` follows the panel** (Claude) - switching to a conversation makes plain `claude -c` in that project resume that same conversation, including one that has been compacted, which Claude Code otherwise refuses to continue. Launching repairs a compacted conversation the same way, but only switching moves what `claude -c` picks
35
35
  - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id with last-activity time; "Manage Sessions..." opens a searchable popup over the full list with multi-select delete and per-row open and copy-id buttons
36
- - **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's `codex fork`, server-side copies for Kimi and Gemini) behind the same menu item
36
+ - **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's `codex fork`, server-side copies for Kimi, Gemini and DeepSeek) behind the same menu item
37
37
  - **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo`, Gemini's YOLO; unsafe variants carry a shield glyph in the launch menus
38
- - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex and Gemini have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
38
+ - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex, Gemini and DeepSeek have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
39
39
  - **Your own tab colour wins** - set a colour on a terminal tab and the extension remembers it for that conversation, overriding whatever the assistant chose. `Reset Tab Colour (n)` in the session's right-click menu hands back every colour you set by hand on that project's conversations, and appears once there is one to hand back. Clearing the colour on the tab itself releases it too, for the conversation that terminal is running; `Reset Tab Colour` is the way back for a conversation with no tab open. A Codex or Kimi conversation that has never been resumed cannot be tracked yet, so a colour set on its tab is not kept
40
40
  - **Favorites** - star projects you keep coming back to via the right-click menu; favourites from the standalone extensions are migrated on first run
41
41
  - **Remove and clean up** - drop a project's history or a project's extra parallel sessions from the right-click menu, confirmation dialog first; removed files honour JupyterLab's "move files to trash" setting
@@ -44,13 +44,14 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
44
44
  - **Search** - fuzzy filter per panel, toggled by the funnel button
45
45
  - **Presentation modes** - label rows by session name or by path relative to the JupyterLab root
46
46
  - **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant instead of showing a duplicate
47
+ - **DeepSeek in the browser** - the DeepSeek Harness has no terminal chat; its panel lists the projects under `~/.dsh/sessions` (or `$DSH_HOME`) and `Open Web UI` starts `dsh --profile web --no-open --port 0` in the project's terminal, which prints the URL to open. The conversation is then chosen in the harness's own browser UI; branching and deleting work from the panel as for every other assistant. The URL is on the machine running JupyterLab, so a remote server needs a port forward to reach it
47
48
  - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
48
49
 
49
50
  ## Requirements
50
51
 
51
- - JupyterLab >= 4.0.0
52
+ - JupyterLab >= 4.6.0
52
53
  - Python >= 3.10
53
- - At least one assistant CLI on `PATH`: `claude`, `codex`, `kimi`, or `gemini`
54
+ - At least one assistant CLI on `PATH`: `claude`, `codex`, `kimi`, `gemini`, or `dsh` (the DeepSeek Harness)
54
55
 
55
56
  ## Install
56
57
 
@@ -60,6 +61,8 @@ Developers must install via the project `Makefile` (which orchestrates clean, bu
60
61
  make install
61
62
  ```
62
63
 
64
+ The companion packages are version floors, so `make install` here pulls their published releases and replaces a locally built `jupyterlab_colourful_tab_extension` or `jupyterlab_basic_terminal_extension`. When verifying a change that spans this extension and a companion, install the companion last.
65
+
63
66
  End-users can install the published package from PyPI:
64
67
 
65
68
  ```bash
@@ -0,0 +1,18 @@
1
+ /** The distinguishing part of a conversation id: its first eight characters
2
+ * past `prefix`, the constant every id of the assistant carries
3
+ * (`IProviderDescriptor.sessionIdPrefix`). Front-sliced when there is no
4
+ * prefix, or when the id does not carry it. */
5
+ export declare function shortSessionId(sessionId: string, prefix?: string): string;
6
+ export declare const MENU_TITLE_COLUMNS = 60;
7
+ /** Trim `text` to `maxColumns` display columns, appending an ellipsis when
8
+ * anything was cut. Iterates by CODE POINT, so an astral character is never
9
+ * split into a lone surrogate (which chromium paints as the replacement
10
+ * glyph). Returns `text` unchanged when it already fits. */
11
+ export declare function truncateToColumns(text: string, maxColumns: number): string;
12
+ /** Menu-item label for one branch: the conversation title trimmed to the
13
+ * column budget, followed by the short session id. Only the TITLE is trimmed -
14
+ * branches of one project share a path, so the id is what tells them apart and
15
+ * it must survive. The bracketed id is dropped when the title already IS the
16
+ * id (the server's last-resort fallback), which can never be truncated since a
17
+ * short id is at most 8 characters. */
18
+ export declare function branchMenuLabel(label: string, shortId: string): string;
@@ -0,0 +1,68 @@
1
+ // Label derivations of the panel: pure string functions, kept free of
2
+ // JupyterLab imports so the unit tests load them without the panel's DOM.
3
+ /** The distinguishing part of a conversation id: its first eight characters
4
+ * past `prefix`, the constant every id of the assistant carries
5
+ * (`IProviderDescriptor.sessionIdPrefix`). Front-sliced when there is no
6
+ * prefix, or when the id does not carry it. */
7
+ export function shortSessionId(sessionId, prefix) {
8
+ const start = prefix && sessionId.startsWith(prefix) ? prefix.length : 0;
9
+ return sessionId.slice(start, start + 8);
10
+ }
11
+ // Lumino sets no `max-width` on `.lm-Menu-itemLabel`, so an uncapped
12
+ // auto-generated conversation title stretches a submenu across the window.
13
+ // Budget is in COLUMNS, not characters. Han, Kana, Hangul and emoji render at
14
+ // roughly one em against Latin's half, so counting characters bounds nothing:
15
+ // 60 Han glyphs measured 851-862px in a real Lumino submenu, at or above the
16
+ // 850px that filed the defect in the first place. An assistant whose titles
17
+ // are model-generated makes a wide-script title the expected case.
18
+ export const MENU_TITLE_COLUMNS = 60;
19
+ // East Asian Wide and Fullwidth ranges, plus emoji. Deliberately coarse: this
20
+ // bounds a menu width, so a handful of mis-classified glyphs cost pixels, not
21
+ // correctness. Anything outside these ranges counts as one column.
22
+ const WIDE_RANGES = [
23
+ [0x1100, 0x115f], // Hangul Jamo
24
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
25
+ [0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, Hangul Compat, CJK Compat
26
+ [0x3400, 0x4dbf], // CJK Unified Extension A
27
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
28
+ [0xa000, 0xa4cf], // Yi
29
+ [0xac00, 0xd7a3], // Hangul syllables
30
+ [0xf900, 0xfaff], // CJK Compatibility Ideographs
31
+ [0xfe30, 0xfe6f], // CJK Compatibility Forms
32
+ [0xff00, 0xff60], // Fullwidth forms
33
+ [0xffe0, 0xffe6], // Fullwidth signs
34
+ [0x1f300, 0x1faff], // Emoji and pictographs
35
+ [0x20000, 0x3fffd] // CJK Unified Extensions B and beyond
36
+ ];
37
+ function isWide(codePoint) {
38
+ return WIDE_RANGES.some(([lo, hi]) => codePoint >= lo && codePoint <= hi);
39
+ }
40
+ /** Trim `text` to `maxColumns` display columns, appending an ellipsis when
41
+ * anything was cut. Iterates by CODE POINT, so an astral character is never
42
+ * split into a lone surrogate (which chromium paints as the replacement
43
+ * glyph). Returns `text` unchanged when it already fits. */
44
+ export function truncateToColumns(text, maxColumns) {
45
+ let columns = 0;
46
+ let kept = '';
47
+ for (const ch of text) {
48
+ const width = isWide(ch.codePointAt(0)) ? 2 : 1;
49
+ if (columns + width > maxColumns) {
50
+ // The cut can land just after a space; drop it so the ellipsis hugs the
51
+ // last word. `trimEnd` is ES2019 and this project targets ES2018.
52
+ return `${kept.replace(/\s+$/, '')}…`;
53
+ }
54
+ columns += width;
55
+ kept += ch;
56
+ }
57
+ return text;
58
+ }
59
+ /** Menu-item label for one branch: the conversation title trimmed to the
60
+ * column budget, followed by the short session id. Only the TITLE is trimmed -
61
+ * branches of one project share a path, so the id is what tells them apart and
62
+ * it must survive. The bracketed id is dropped when the title already IS the
63
+ * id (the server's last-resort fallback), which can never be truncated since a
64
+ * short id is at most 8 characters. */
65
+ export function branchMenuLabel(label, shortId) {
66
+ const title = truncateToColumns(label, MENU_TITLE_COLUMNS);
67
+ return label === shortId ? title : `${title} (${shortId})`;
68
+ }
@@ -79,6 +79,10 @@ export declare class AssistantSessionsPanel extends Widget {
79
79
  private _logError;
80
80
  private _fetch;
81
81
  private _toggleFavourite;
82
+ /** The warning a permanent deletion earns and a trashed one does not - a
83
+ * trashed conversation is recoverable, and the popup one click away says
84
+ * so; the same sentence on both would be false half the time. */
85
+ private get _irreversibleNote();
82
86
  private get _disposalVerb();
83
87
  private _removeProject;
84
88
  private _cleanupParallel;
@@ -212,8 +216,7 @@ export declare class AssistantSessionsPanel extends Widget {
212
216
  * cannot give. */
213
217
  private _formatAbsoluteTime;
214
218
  /** Branch entry display: conversation name plus short session id. Branches
215
- * share a project path, so the name and id are all that tell them apart. The
216
- * suffix is dropped when the label already IS the short id. */
219
+ * share a project path, so the name and id are all that tell them apart. */
217
220
  private _branchDisplayName;
218
221
  /** Menu-item label for a branch: display name, relative time, and a trailing
219
222
  * marker when a live worker owns the conversation. The marker lives in the
package/lib/core/panel.js CHANGED
@@ -5,6 +5,7 @@ import { CommandRegistry } from '@lumino/commands';
5
5
  import { UUID } from '@lumino/coreutils';
6
6
  import { Widget } from '@lumino/widgets';
7
7
  import { ColourStore } from './colour';
8
+ import { branchMenuLabel, shortSessionId } from './labels';
8
9
  import { addIcon, branchIcon, cleanupIcon, filterIcon, providerIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon, statusDotIcon, switchIcon } from './icons';
9
10
  import { DEFAULT_RECENT_LIMIT, MAX_RECENT_LIMIT, MIN_RECENT_LIMIT } from './limits';
10
11
  import { resolveLaunchMode, resolvedLaunchModeEntry } from './modes';
@@ -586,6 +587,12 @@ export class AssistantSessionsPanel extends Widget {
586
587
  this._showActionError('Could not toggle favorite - try again.', err);
587
588
  }
588
589
  }
590
+ /** The warning a permanent deletion earns and a trashed one does not - a
591
+ * trashed conversation is recoverable, and the popup one click away says
592
+ * so; the same sentence on both would be false half the time. */
593
+ get _irreversibleNote() {
594
+ return this._deleteToTrash ? '' : ' This cannot be undone.';
595
+ }
589
596
  get _disposalVerb() {
590
597
  return this._deleteToTrash ? 'moved to trash' : 'deleted permanently';
591
598
  }
@@ -596,7 +603,7 @@ export class AssistantSessionsPanel extends Widget {
596
603
  title: `Remove from ${this._descriptor.label}`,
597
604
  body: `Remove "${name}" from ${this._descriptor.label}? This drops the ` +
598
605
  `entire project history and every conversation it holds - ` +
599
- `${this._disposalVerb}. This cannot be undone.`,
606
+ `${this._disposalVerb}.${this._irreversibleNote}`,
600
607
  buttons: [
601
608
  Dialog.cancelButton(),
602
609
  Dialog.warnButton({ label: this._trans.__('Remove') })
@@ -637,7 +644,7 @@ export class AssistantSessionsPanel extends Widget {
637
644
  title: this._trans.__('Clean Up Parallel Sessions'),
638
645
  body: `Remove ${extra} parallel session${extra === 1 ? '' : 's'} from ` +
639
646
  `"${name}"? The current conversation is kept; the rest are ` +
640
- `${this._disposalVerb}. This cannot be undone.`,
647
+ `${this._disposalVerb}.${this._irreversibleNote}`,
641
648
  buttons: [
642
649
  Dialog.cancelButton(),
643
650
  Dialog.warnButton({ label: this._trans.__('Remove') })
@@ -839,7 +846,7 @@ export class AssistantSessionsPanel extends Widget {
839
846
  if (row) {
840
847
  // A terminal already holding that conversation IS the answer: a second
841
848
  // process on one history is never what the click meant (ACC-LNCH-152).
842
- const found = await this._terminals.findForSession(row.session_id);
849
+ const found = await this._terminals.findForSession(row.session_id, folder);
843
850
  if (found) {
844
851
  this._terminals.focus(found.widget);
845
852
  return found.widget;
@@ -1194,6 +1201,7 @@ export class AssistantSessionsPanel extends Widget {
1194
1201
  }
1195
1202
  /** Make another conversation the row's current one. */
1196
1203
  async _switchBranch(session, sessionId) {
1204
+ var _a;
1197
1205
  try {
1198
1206
  const result = await requestProvider(this._descriptor.id, 'switch', this._serverSettings, {
1199
1207
  method: 'POST',
@@ -1205,6 +1213,15 @@ export class AssistantSessionsPanel extends Widget {
1205
1213
  if (result.current !== result.requested) {
1206
1214
  Notification.warning('That conversation could not be made the current one.', { autoClose: 4000 });
1207
1215
  }
1216
+ else {
1217
+ // The re-render is the only other signal, and it can read as the
1218
+ // opposite of success: the row now reports the conversation's true
1219
+ // last activity, so switching to an old one dims the row and drops
1220
+ // it out of Recent (DEF-PANE-181).
1221
+ const target = this._lastBranches.find(b => b.session_id === sessionId);
1222
+ const label = (_a = target === null || target === void 0 ? void 0 : target.label) !== null && _a !== void 0 ? _a : shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
1223
+ Notification.success(`${this._lookupName(session)}: switched to ${label}`, { autoClose: 3000 });
1224
+ }
1208
1225
  }
1209
1226
  catch (err) {
1210
1227
  Notification.error(isResponseStatus(err, 404)
@@ -1486,17 +1503,6 @@ export class AssistantSessionsPanel extends Widget {
1486
1503
  row.classList.add('jp-mod-stale');
1487
1504
  }
1488
1505
  }
1489
- const removing = this._removingPaths.has(session.encoded_path);
1490
- if (removing) {
1491
- row.classList.add('jp-mod-busy');
1492
- const spinner = document.createElement('span');
1493
- spinner.className = 'jp-AiAssistantsPanel-spinner';
1494
- spinner.title = this._trans.__('Removing...');
1495
- row.appendChild(spinner);
1496
- }
1497
- else {
1498
- row.appendChild(this._renderIndicator(session));
1499
- }
1500
1506
  const name = document.createElement('span');
1501
1507
  name.className = 'jp-AiAssistantsPanel-name';
1502
1508
  // The text ellipsises in a span of its own so the badges after it survive
@@ -1531,6 +1537,21 @@ export class AssistantSessionsPanel extends Widget {
1531
1537
  name.appendChild(bg);
1532
1538
  }
1533
1539
  row.appendChild(name);
1540
+ // The status dot (or the removal spinner in its place) leads the row
1541
+ // visually - CSS `order` puts it first - but follows the name in the DOM,
1542
+ // so a screen reader announces the project before its status sentence
1543
+ // rather than the same preamble ahead of every active row (DEF-PANE-183).
1544
+ const removing = this._removingPaths.has(session.encoded_path);
1545
+ if (removing) {
1546
+ row.classList.add('jp-mod-busy');
1547
+ const spinner = document.createElement('span');
1548
+ spinner.className = 'jp-AiAssistantsPanel-spinner';
1549
+ spinner.title = this._trans.__('Removing...');
1550
+ row.appendChild(spinner);
1551
+ }
1552
+ else {
1553
+ row.appendChild(this._renderIndicator(session));
1554
+ }
1534
1555
  // No star in the Favorites section - every row there is one by definition.
1535
1556
  // The star sits before the time so the fixed-width time column stays the
1536
1557
  // rightmost alignment anchor across all rows.
@@ -1759,14 +1780,9 @@ export class AssistantSessionsPanel extends Widget {
1759
1780
  `${pad(d.getHours())}:${pad(d.getMinutes())}`);
1760
1781
  }
1761
1782
  /** Branch entry display: conversation name plus short session id. Branches
1762
- * share a project path, so the name and id are all that tell them apart. The
1763
- * suffix is dropped when the label already IS the short id. */
1783
+ * share a project path, so the name and id are all that tell them apart. */
1764
1784
  _branchDisplayName(b) {
1765
- const shortId = b.session_id.slice(0, 8);
1766
- if (this._hooks.branchLabel) {
1767
- return this._hooks.branchLabel(b, shortId);
1768
- }
1769
- return b.label === shortId ? b.label : `${b.label} (${shortId})`;
1785
+ return branchMenuLabel(b.label, shortSessionId(b.session_id, this._descriptor.sessionIdPrefix));
1770
1786
  }
1771
1787
  /** Menu-item label for a branch: display name, relative time, and a trailing
1772
1788
  * marker when a live worker owns the conversation. The marker lives in the
@@ -2186,10 +2202,14 @@ export class AssistantSessionsPanel extends Widget {
2186
2202
  add('reset-colour');
2187
2203
  this._contextMenu.addItem({ type: 'separator' });
2188
2204
  if (withBranches) {
2189
- this._contextMenu.addItem({
2190
- type: 'submenu',
2191
- submenu: this._openBranchSubmenu
2192
- });
2205
+ // A project-scoped assistant has one terminal for every conversation,
2206
+ // so opening a branch in its own terminal is not an action it has.
2207
+ if (this._descriptor.terminalScope === 'conversation') {
2208
+ this._contextMenu.addItem({
2209
+ type: 'submenu',
2210
+ submenu: this._openBranchSubmenu
2211
+ });
2212
+ }
2193
2213
  this._contextMenu.addItem({
2194
2214
  type: 'submenu',
2195
2215
  submenu: this._switchSubmenu
@@ -2351,11 +2371,12 @@ export class AssistantSessionsPanel extends Widget {
2351
2371
  if (!session) {
2352
2372
  return;
2353
2373
  }
2374
+ const current = this._lastBranchesCurrent || session.session_id;
2354
2375
  showManageSessionsPopup({
2355
2376
  trans: this._trans,
2356
2377
  branches: this._lastBranches,
2357
- current: this._lastBranchesCurrent || session.session_id,
2358
- projectName: this._lookupName(session),
2378
+ current,
2379
+ currentName: `${this._lookupName(session)} (${shortSessionId(current, this._descriptor.sessionIdPrefix)})`,
2359
2380
  deleteToTrash: this._deleteToTrash,
2360
2381
  branchName: b => this._branchDisplayName(b),
2361
2382
  formatTime: ms => this._formatRelativeTime(ms),
@@ -2376,11 +2397,16 @@ export class AssistantSessionsPanel extends Widget {
2376
2397
  * is attached to and carries none. The row's own conversation is not in
2377
2398
  * `_lastBranches`, so it answers from the session. */
2378
2399
  _openBranchTitle(session, sessionId) {
2379
- var _a;
2400
+ var _a, _b, _c, _d;
2401
+ // One terminal serves a project-scoped assistant, so the button focuses
2402
+ // or starts it, under the provider's own verb for that.
2403
+ if (this._descriptor.terminalScope === 'project') {
2404
+ return ((_c = (_b = (_a = this._hooks).resumeLabel) === null || _b === void 0 ? void 0 : _b.call(_a, session)) !== null && _c !== void 0 ? _c : "Open the project's terminal");
2405
+ }
2380
2406
  const base = 'Open this conversation in its own terminal';
2381
2407
  const target = sessionId === session.session_id
2382
2408
  ? session
2383
- : ((_a = this._lastBranches.find(b => b.session_id === sessionId)) !== null && _a !== void 0 ? _a : null);
2409
+ : ((_d = this._lastBranches.find(b => b.session_id === sessionId)) !== null && _d !== void 0 ? _d : null);
2384
2410
  const variant = this._resumeVariant(target);
2385
2411
  return variant ? `${base} (${variant.label})` : base;
2386
2412
  }
@@ -18,8 +18,9 @@ export declare namespace ManageSessionsPopup {
18
18
  branches: IBranch[];
19
19
  /** The project's current conversation id, pinned at the top. */
20
20
  current: string;
21
- /** Display name of the row this popup was opened from. */
22
- projectName: string;
21
+ /** Display name of the current row: the project's name and the short id
22
+ * of `current`, derived by the panel with the same rule as `branchName`. */
23
+ currentName: string;
23
24
  /** Whether deletions move to trash - the popup says which it means. */
24
25
  deleteToTrash: boolean;
25
26
  /** Display name for one branch, `<label> (<short id>)` by default. */
package/lib/core/popup.js CHANGED
@@ -158,7 +158,7 @@ export function showManageSessionsPopup(options) {
158
158
  // same structure the branch rows below use.
159
159
  const currentText = document.createElement('span');
160
160
  currentText.className = 'jp-AiAssistantsPanel-branchLabelText';
161
- currentText.textContent = `${options.projectName} (${options.current.slice(0, 8)})`;
161
+ currentText.textContent = options.currentName;
162
162
  currentLabel.appendChild(currentText);
163
163
  currentRow.appendChild(currentLabel);
164
164
  const badge = document.createElement('span');
@@ -245,21 +245,9 @@ export function showManageSessionsPopup(options) {
245
245
  row.appendChild(time);
246
246
  row.appendChild(openButton(b.session_id));
247
247
  row.appendChild(copyButton(b.session_id, trans));
248
+ // A row click always switches, whatever is selected: the checkbox cell
249
+ // is the one select target, so a click has one meaning (DEF-PANE-209).
248
250
  const activate = () => {
249
- // Selection mode: while anything is ticked, row clicks toggle
250
- // selection - no accidental switch mid-selection.
251
- if (selected.size > 0) {
252
- if (selected.has(b.session_id)) {
253
- selected.delete(b.session_id);
254
- }
255
- else {
256
- selected.add(b.session_id);
257
- }
258
- check.checked = selected.has(b.session_id);
259
- disarm();
260
- updateControls();
261
- return;
262
- }
263
251
  dialog.dispose();
264
252
  options.onSwitch(b.session_id);
265
253
  };
@@ -303,7 +291,7 @@ export function showManageSessionsPopup(options) {
303
291
  });
304
292
  // Busy-lock the whole body during the async delete, so a slow backend cannot
305
293
  // be double-clicked into deleting the same set twice and a mid-flight
306
- // selection cannot be silently discarded. The Dialog's Cancel button sits
294
+ // selection cannot be silently discarded. The Dialog's Close button sits
307
295
  // outside the body and stays usable.
308
296
  const setDeleting = (on) => {
309
297
  deleting = on;
@@ -46,6 +46,11 @@ export declare class TerminalManager {
46
46
  * Launch a terminal for one request and focus it. The returned widget is
47
47
  * tagged with `tagSessionId` in the microcache, so a later click on the row
48
48
  * that conversation now belongs to reuses this very terminal.
49
+ *
50
+ * For a project-scoped assistant the project's running terminal is the
51
+ * answer instead, whatever the request asked for: every caller - row click,
52
+ * `+`, fork, Launcher tile - lands here, so this one gate is what keeps a
53
+ * second server off the project.
49
54
  */
50
55
  launch(request: ILaunchRequest, tagSessionId?: string): Promise<any>;
51
56
  /**
@@ -63,11 +68,28 @@ export declare class TerminalManager {
63
68
  * too, not only launches that carry an argv id. An id is globally unique to
64
69
  * one conversation, so a terminal holding it IS the one to focus, whatever
65
70
  * cwd it reports - a process that changed directory, or whose project dir
66
- * was recreated, must still be reused rather than duplicated. */
67
- findForSession(wantedSessionId: string | undefined): Promise<{
71
+ * was recreated, must still be reused rather than duplicated.
72
+ *
73
+ * For a project-scoped assistant the id is not what a terminal holds; the
74
+ * project's running terminal (see `_projectTerminal`) is the answer, so the
75
+ * caller passes the project path. */
76
+ findForSession(wantedSessionId: string | undefined, projectPath?: string): Promise<{
68
77
  widget: any;
69
78
  runningId: string | null;
70
79
  } | null>;
80
+ /** The one terminal serving a project, for a project-scoped assistant, or
81
+ * null. The project's microcache entry whatever conversation it was tagged
82
+ * with, else the first live terminal the server confirms is running this
83
+ * assistant with its working directory inside the project - the same
84
+ * cwd-to-project rule the colour pass applies. The server's `running`
85
+ * answer is what keeps a plain shell opened at the project from matching.
86
+ *
87
+ * The cached terminal is asked too, so a server the user stopped in it does
88
+ * not hold every later open on a dead shell. Only a terminal the server has
89
+ * once confirmed is evicted on a `running: false` answer: a fresh launch
90
+ * answers that until its process is up, and evicting it then would start
91
+ * the second server this method exists to prevent. */
92
+ private _projectTerminal;
71
93
  /** Ask the server what a terminal is running. Null when the widget has no
72
94
  * session name yet, or the probe fails - a terminal can vanish between
73
95
  * enumeration and probe, which is a retry, not an error. */
@@ -99,7 +99,8 @@ export class TerminalManager {
99
99
  // terminal is not painted, because painting is what destroys the pick.
100
100
  this._pendingChoices = new Map();
101
101
  // Most recent terminal per project, tagged with the conversation it runs so
102
- // reuse can tell a project's branches apart.
102
+ // reuse can tell a project's branches apart. `confirmed` records that the
103
+ // server once answered `running: true` for it (project scope only).
103
104
  this._byProject = new Map();
104
105
  // In-flight launches keyed per CONVERSATION, so two branches of one project
105
106
  // open independently and concurrently.
@@ -177,6 +178,17 @@ export class TerminalManager {
177
178
  }
178
179
  async _doOpenSession(session, mode, wanted) {
179
180
  var _a;
181
+ // A project-scoped assistant has no per-conversation terminal to find:
182
+ // `launch` answers with the project's running one or starts it.
183
+ if (this._descriptor.terminalScope === 'project') {
184
+ await this.launch({
185
+ project_path: session.project_path,
186
+ encoded_path: session.encoded_path,
187
+ session_id: wanted,
188
+ mode
189
+ }, wanted);
190
+ return;
191
+ }
180
192
  // 1. Microcache - the most recent terminal of this project, reused only
181
193
  // when it carries the wanted conversation.
182
194
  const cached = this._byProject.get(session.project_path);
@@ -211,8 +223,26 @@ export class TerminalManager {
211
223
  * Launch a terminal for one request and focus it. The returned widget is
212
224
  * tagged with `tagSessionId` in the microcache, so a later click on the row
213
225
  * that conversation now belongs to reuses this very terminal.
226
+ *
227
+ * For a project-scoped assistant the project's running terminal is the
228
+ * answer instead, whatever the request asked for: every caller - row click,
229
+ * `+`, fork, Launcher tile - lands here, so this one gate is what keeps a
230
+ * second server off the project.
214
231
  */
215
232
  async launch(request, tagSessionId) {
233
+ if (this._descriptor.terminalScope === 'project') {
234
+ const running = await this._projectTerminal(request.project_path);
235
+ if (running) {
236
+ // A new conversation still settles the project's pin (DEF-9): the
237
+ // argv route runs the launch's validation and pin bookkeeping without
238
+ // spawning, and the argv it answers with is not needed here.
239
+ if (request.session_id === undefined && request.encoded_path) {
240
+ await requestProvider(this._descriptor.id, 'launch-argv', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
241
+ }
242
+ this.focus(running);
243
+ return running;
244
+ }
245
+ }
216
246
  const spinner = showLaunchSpinner(`Opening ${this._descriptor.label}`, this._trans);
217
247
  try {
218
248
  const launched = await requestProvider(this._descriptor.id, 'launch', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
@@ -262,8 +292,18 @@ export class TerminalManager {
262
292
  * too, not only launches that carry an argv id. An id is globally unique to
263
293
  * one conversation, so a terminal holding it IS the one to focus, whatever
264
294
  * cwd it reports - a process that changed directory, or whose project dir
265
- * was recreated, must still be reused rather than duplicated. */
266
- async findForSession(wantedSessionId) {
295
+ * was recreated, must still be reused rather than duplicated.
296
+ *
297
+ * For a project-scoped assistant the id is not what a terminal holds; the
298
+ * project's running terminal (see `_projectTerminal`) is the answer, so the
299
+ * caller passes the project path. */
300
+ async findForSession(wantedSessionId, projectPath) {
301
+ if (this._descriptor.terminalScope === 'project') {
302
+ const widget = projectPath
303
+ ? await this._projectTerminal(projectPath)
304
+ : null;
305
+ return widget ? { widget, runningId: null } : null;
306
+ }
267
307
  if (!this._tracker || !wantedSessionId) {
268
308
  return null;
269
309
  }
@@ -275,6 +315,50 @@ export class TerminalManager {
275
315
  }
276
316
  return null;
277
317
  }
318
+ /** The one terminal serving a project, for a project-scoped assistant, or
319
+ * null. The project's microcache entry whatever conversation it was tagged
320
+ * with, else the first live terminal the server confirms is running this
321
+ * assistant with its working directory inside the project - the same
322
+ * cwd-to-project rule the colour pass applies. The server's `running`
323
+ * answer is what keeps a plain shell opened at the project from matching.
324
+ *
325
+ * The cached terminal is asked too, so a server the user stopped in it does
326
+ * not hold every later open on a dead shell. Only a terminal the server has
327
+ * once confirmed is evicted on a `running: false` answer: a fresh launch
328
+ * answers that until its process is up, and evicting it then would start
329
+ * the second server this method exists to prevent. */
330
+ async _projectTerminal(projectPath) {
331
+ var _a, _b;
332
+ const cached = this._byProject.get(projectPath);
333
+ if (cached && !cached.widget.isDisposed) {
334
+ const info = await this.probe(cached.widget);
335
+ if (info === null || info === void 0 ? void 0 : info.running) {
336
+ cached.confirmed = true;
337
+ }
338
+ if ((info === null || info === void 0 ? void 0 : info.running) !== false || !cached.confirmed) {
339
+ return cached.widget;
340
+ }
341
+ this._byProject.delete(projectPath);
342
+ }
343
+ if (!this._tracker) {
344
+ return null;
345
+ }
346
+ for (const widget of this._liveTerminals()) {
347
+ const info = await this.probe(widget);
348
+ if ((info === null || info === void 0 ? void 0 : info.running) &&
349
+ ((_b = sessionForCwds((_a = info.cwds) !== null && _a !== void 0 ? _a : [], this._sessions)) === null || _b === void 0 ? void 0 : _b.project_path) ===
350
+ projectPath) {
351
+ this._byProject.set(projectPath, {
352
+ widget,
353
+ sessionId: undefined,
354
+ confirmed: true
355
+ });
356
+ this._wireDisposal(projectPath, widget);
357
+ return widget;
358
+ }
359
+ }
360
+ return null;
361
+ }
278
362
  /** Ask the server what a terminal is running. Null when the widget has no
279
363
  * session name yet, or the probe fails - a terminal can vanish between
280
364
  * enumeration and probe, which is a retry, not an error. */
@@ -15,6 +15,8 @@
15
15
  * or read that value.
16
16
  */
17
17
  export type ForkStrategy = 'native-flag' | 'native-command' | 'server-copy';
18
+ /** See `IProviderDescriptor.terminalScope`. */
19
+ export type TerminalScope = 'conversation' | 'project';
18
20
  /** Where a conversation's DEFAULT tab tint comes from. A user-set colour in
19
21
  * the extension's own write-back store always wins over all three. */
20
22
  export type ColourSource = 'native' | 'derived' | 'none';
@@ -56,6 +58,14 @@ export interface IProviderDescriptor {
56
58
  cliBinary: string;
57
59
  forkStrategy: ForkStrategy;
58
60
  colourSource: ColourSource;
61
+ /** What one terminal of this assistant holds. `conversation`: the process
62
+ * runs exactly one conversation, so a terminal is reused only when the
63
+ * server reads the wanted id off it. `project`: the process serves every
64
+ * conversation of the project (a web UI chosen in the browser), so the
65
+ * project's one running terminal is the answer to every open - a row click,
66
+ * `+`, a fork, a Launcher tile - and a second process on the same project
67
+ * is always a duplicate. */
68
+ terminalScope: TerminalScope;
59
69
  /** Whether the branch flow asks the user to name the new conversation.
60
70
  * Where that name then GOES is `forkStrategy`'s job. */
61
71
  promptsForBranchName: boolean;
@@ -65,6 +75,12 @@ export interface IProviderDescriptor {
65
75
  * `forkStrategy` - an assistant can accept an id for a new session and still
66
76
  * have no fork verb at all. */
67
77
  mintsNewSessionId: boolean;
78
+ /** A constant prefix every conversation id of this assistant carries, e.g.
79
+ * `session_`. The short id shown beside a branch is sliced past it, since a
80
+ * front-slice of such an id would render the same eight characters for
81
+ * every conversation of a project - the exact thing the short id exists to
82
+ * tell apart. Absent for an assistant whose ids are bare uuids. */
83
+ sessionIdPrefix?: string;
68
84
  launchModes: ILaunchMode[];
69
85
  /** Sessions can be held under remote control - renders the indicator dot. */
70
86
  hasRemoteControl: boolean;
@@ -286,10 +302,6 @@ export interface IProviderHooks {
286
302
  resumeLabel?: (session: ISession | null) => string;
287
303
  /** Extra lines for the row tooltip. */
288
304
  tooltipLines?: (session: ISession) => string[];
289
- /** Menu label for one branch. Defaults to `<label> (<short id>) - <time>`;
290
- * a provider whose titles are written in wide scripts overrides it to bound
291
- * the menu by display columns rather than by code units. */
292
- branchLabel?: (branch: IBranch, shortId: string) => string;
293
305
  }
294
306
  /** What `providers/index.ts` registers: one descriptor, optionally with hooks. */
295
307
  export interface IProviderModule {
@@ -42,6 +42,7 @@ export const descriptor = {
42
42
  // its transcript, so the assistant supplies the DEFAULT tint - a colour the
43
43
  // user then sets on the tab diverges from it deliberately and wins.
44
44
  colourSource: 'native',
45
+ terminalScope: 'conversation',
45
46
  // `-n <name>` at launch: Claude stamps the name itself and re-stamps it on
46
47
  // every turn, which is the only way a fork's name survives - a title written
47
48
  // after the fact loses to the parent title the fork inherits.
@@ -38,6 +38,7 @@ export const descriptor = {
38
38
  forkStrategy: 'native-command',
39
39
  // No `/color` equivalent, and nothing in the thread store records one.
40
40
  colourSource: 'none',
41
+ terminalScope: 'conversation',
41
42
  // Forks are unnamed - Codex has no naming flag and no writable title field,
42
43
  // so the panel never asks for a name it could not stamp anywhere.
43
44
  promptsForBranchName: false,