From 8c1d4cfb925ad769c4e86d05fb86347394862b8f Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Sat, 16 May 2026 13:34:24 -0400 Subject: [PATCH 1/2] feat(launcher): add tiles for opencode, Pi, and GitHub Copilot CLI Issue #260: the Coding Agent launcher category only had Claude Code. First-phase scope per the issue body: detect the CLI on PATH, show a tile when present, click opens a Jupyter terminal in the file-browser's current directory and types the CLI command. No session picker. Backend: generalized resolve_claude_cli_path into a generic _resolve_cli_path(name, env_var) backed by a single dict cache. Added resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path as thin wrappers, each with an NBI__CLI_PATH env override that matches the existing Claude pattern. Capabilities response gains opencode_cli_available, pi_cli_available, github_copilot_cli_available flags. Frontend: added matching isOpenCodeCliAvailable / isPiCliAvailable / isGitHubCopilotCliAvailable getters on NBIAPI.config. New registerAgentCliLauncher helper takes (label, caption, icon, cliCommand, isAvailable) and wires the command + tile + configChanged refresh in one place. The Claude tile keeps its bespoke session-picker UX and isn't routed through the helper (helper API would have to balloon to cover the one outlier; not worth it). Renamed launchClaudeInTerminal to launchCliInTerminal since three new callers needed it. Icons: opencode and Pi use JL's terminalIcon as a first-phase placeholder (matches "this opens a terminal"). GitHub Copilot CLI reuses the existing githubCopilotIcon. Brand-specific icons can be a follow-up. No new tests: the new util helpers are direct wrappers around the existing memoized shutil.which path, which the rest of the codebase already exercises; tile registration is JL framework boilerplate unreachable from jest without a real lab application context. Closes #260 --- notebook_intelligence/extension.py | 9 +++- notebook_intelligence/util.py | 55 ++++++++++++++-------- src/api.ts | 12 +++++ src/command-ids.ts | 5 ++ src/index.ts | 74 +++++++++++++++++++++++++++--- 5 files changed, 128 insertions(+), 27 deletions(-) diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 109c9f95..4720d50b 100644 --- a/notebook_intelligence/extension.py +++ b/notebook_intelligence/extension.py @@ -52,7 +52,7 @@ ) import notebook_intelligence.github_copilot as github_copilot from notebook_intelligence.built_in_toolsets import built_in_toolsets -from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, resolve_claude_cli_path +from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path from notebook_intelligence.context_factory import RuleContextFactory from notebook_intelligence.skillset import SKILL_NAME_REGEX @@ -482,8 +482,13 @@ def is_provider_enabled(provider_id: str) -> bool: nbi_config.claude_settings, self.string_overrides ), "claude_models": ai_service_manager.claude_models, - # Drives launcher-tile visibility (issue #183). + # Drive launcher-tile visibility (issues #183, #260). Each flag + # gates one tile under the "Coding Agent" category. Detection is + # PATH-based with NBI_*_CLI_PATH env overrides. "claude_cli_available": resolve_claude_cli_path() is not None, + "opencode_cli_available": resolve_opencode_cli_path() is not None, + "pi_cli_available": resolve_pi_cli_path() is not None, + "github_copilot_cli_available": resolve_copilot_cli_path() is not None, "default_chat_mode": nbi_config.default_chat_mode, "chat_feedback_enabled": self.enable_chat_feedback, # Single source of truth lives on each domain's base handler so diff --git a/notebook_intelligence/util.py b/notebook_intelligence/util.py index b0238000..ef76c5a0 100644 --- a/notebook_intelligence/util.py +++ b/notebook_intelligence/util.py @@ -22,32 +22,51 @@ def get_jupyter_root_dir() -> str: return _jupyter_root_dir -_UNSET = object() -_cached_which_claude: object = _UNSET +_cached_cli_paths: dict[str, Optional[str]] = {} -def resolve_claude_cli_path() -> Optional[str]: - """Resolve the Claude Code CLI binary path. - - NBI_CLAUDE_CLI_PATH wins when set; otherwise fall back to the first - `claude` on PATH. Returns None when nothing is found, so callers can - decide between raising and proceeding without the CLI. +def _resolve_cli_path(name: str, env_var: str) -> Optional[str]: + """Resolve an agent-CLI binary path with env override + PATH fallback. - The PATH lookup is memoized — capabilities is hot and PATH doesn't - change at runtime. Use ``invalidate_claude_cli_cache`` from tests. + The env var wins when set; otherwise this is a memoized shutil.which. + Capabilities is a hot endpoint and PATH doesn't change at runtime, so + every lookup goes through this single cache. Tests should call + ``invalidate_cli_cache`` between cases that toggle the env or PATH. """ - explicit = os.getenv("NBI_CLAUDE_CLI_PATH") + explicit = os.getenv(env_var) if explicit: return explicit - global _cached_which_claude - if _cached_which_claude is _UNSET: - _cached_which_claude = shutil.which("claude") - return _cached_which_claude # type: ignore[return-value] + if name not in _cached_cli_paths: + _cached_cli_paths[name] = shutil.which(name) + return _cached_cli_paths[name] + + +def resolve_claude_cli_path() -> Optional[str]: + """Claude Code CLI. NBI_CLAUDE_CLI_PATH overrides; else `claude` on PATH.""" + return _resolve_cli_path("claude", "NBI_CLAUDE_CLI_PATH") + + +def resolve_opencode_cli_path() -> Optional[str]: + """opencode CLI. NBI_OPENCODE_CLI_PATH overrides; else `opencode` on PATH.""" + return _resolve_cli_path("opencode", "NBI_OPENCODE_CLI_PATH") + + +def resolve_pi_cli_path() -> Optional[str]: + """Pi CLI. NBI_PI_CLI_PATH overrides; else `pi` on PATH.""" + return _resolve_cli_path("pi", "NBI_PI_CLI_PATH") + + +def resolve_copilot_cli_path() -> Optional[str]: + """GitHub Copilot CLI. NBI_GITHUB_COPILOT_CLI_PATH overrides; else `copilot` on PATH.""" + return _resolve_cli_path("copilot", "NBI_GITHUB_COPILOT_CLI_PATH") -def invalidate_claude_cli_cache() -> None: - global _cached_which_claude - _cached_which_claude = _UNSET +def invalidate_cli_cache(name: Optional[str] = None) -> None: + """Clear the memoized CLI-path cache. Pass a name to invalidate just one.""" + if name is None: + _cached_cli_paths.clear() + else: + _cached_cli_paths.pop(name, None) def resolve_github_token() -> Optional[str]: diff --git a/src/api.ts b/src/api.ts index ea14e8f5..6b7053c6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -356,6 +356,18 @@ export class NBIConfig { return this.capabilities.claude_cli_available === true; } + get isOpenCodeCliAvailable(): boolean { + return this.capabilities.opencode_cli_available === true; + } + + get isPiCliAvailable(): boolean { + return this.capabilities.pi_cli_available === true; + } + + get isGitHubCopilotCliAvailable(): boolean { + return this.capabilities.github_copilot_cli_available === true; + } + get chatFeedbackEnabled(): boolean { return this.capabilities.chat_feedback_enabled === true; } diff --git a/src/command-ids.ts b/src/command-ids.ts index 29f3d75d..b68e4e88 100644 --- a/src/command-ids.ts +++ b/src/command-ids.ts @@ -56,4 +56,9 @@ export namespace CommandIDs { 'notebook-intelligence:run-command-in-terminal'; export const openClaudeCodeLauncher = 'notebook-intelligence:open-claude-code-launcher'; + export const openOpenCodeLauncher = + 'notebook-intelligence:open-opencode-launcher'; + export const openPiLauncher = 'notebook-intelligence:open-pi-launcher'; + export const openGitHubCopilotCliLauncher = + 'notebook-intelligence:open-github-copilot-cli-launcher'; } diff --git a/src/index.ts b/src/index.ts index 87d8e90d..f75aa248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,7 @@ import { FileBrowserModel, IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ContentsManager, KernelSpecManager } from '@jupyterlab/services'; -import { LabIcon } from '@jupyterlab/ui-components'; +import { LabIcon, terminalIcon } from '@jupyterlab/ui-components'; import { Menu, Panel, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; @@ -1232,7 +1232,7 @@ const plugin: JupyterFrontEndPlugin = { // Waits for bash's first prompt before sending, avoiding the race condition // where the command is sent before the shell has started. - const launchClaudeInTerminal = async ( + const launchCliInTerminal = async ( command: string, cwd?: string ): Promise => { @@ -1288,7 +1288,7 @@ const plugin: JupyterFrontEndPlugin = { const cmd = session.cwd ? `cd ${session.cwd} && claude --resume ${session.session_id}` : `claude --resume ${session.session_id}`; - launchClaudeInTerminal(cmd); + launchCliInTerminal(cmd); } }); } @@ -1310,7 +1310,7 @@ const plugin: JupyterFrontEndPlugin = { // New Session: open the terminal at whatever subdirectory the file // browser is currently viewing, mirroring how Jupyter's own // terminal launcher behaves (issue #182). - launchClaudeInTerminal('claude', defaultBrowser?.model.path); + launchCliInTerminal('claude', defaultBrowser?.model.path); } } }); @@ -1323,9 +1323,69 @@ const plugin: JupyterFrontEndPlugin = { }); } - // Refresh the launcher tile's enabled/visible state when the user - // toggles Claude Code mode or installs the CLI. Without this, the - // tile keeps its initial-load decision until full reload. + // Additional coding-agent CLIs (issue #260). First-phase scope: detect + // the binary on PATH (backend exposes `_cli_available`), show a + // tile when present, click opens a terminal in the file-browser's + // current directory and types the CLI command. No session picker. + const registerAgentCliLauncher = (config: { + commandId: string; + label: string; + caption: string; + icon: LabIcon; + cliCommand: string; + isAvailable: () => boolean; + }) => { + app.commands.addCommand(config.commandId, { + label: config.label, + caption: config.caption, + icon: config.icon, + isVisible: () => config.isAvailable(), + execute: () => { + launchCliInTerminal(config.cliCommand, defaultBrowser?.model.path); + } + }); + if (launcher) { + launcher.add({ + command: config.commandId, + category: 'Coding Agent' + }); + } + NBIAPI.configChanged.connect(() => { + app.commands.notifyCommandChanged(config.commandId); + }); + }; + + registerAgentCliLauncher({ + commandId: CommandIDs.openOpenCodeLauncher, + label: 'opencode', + caption: 'Start an opencode session in a Jupyter terminal', + icon: terminalIcon, + cliCommand: 'opencode', + isAvailable: () => NBIAPI.config.isOpenCodeCliAvailable + }); + + registerAgentCliLauncher({ + commandId: CommandIDs.openPiLauncher, + label: 'Pi', + caption: 'Start a Pi session in a Jupyter terminal', + icon: terminalIcon, + cliCommand: 'pi', + isAvailable: () => NBIAPI.config.isPiCliAvailable + }); + + registerAgentCliLauncher({ + commandId: CommandIDs.openGitHubCopilotCliLauncher, + label: 'GitHub Copilot CLI', + caption: 'Start a GitHub Copilot CLI session in a Jupyter terminal', + icon: githubCopilotIcon, + cliCommand: 'copilot', + isAvailable: () => NBIAPI.config.isGitHubCopilotCliAvailable + }); + + // Refresh the Claude Code launcher tile's enabled/visible state when the + // user toggles Claude Code mode or installs the CLI. Without this, the + // tile keeps its initial-load decision until full reload. The other + // agent-CLI tiles are wired the same way inside registerAgentCliLauncher. NBIAPI.configChanged.connect(() => { app.commands.notifyCommandChanged(CommandIDs.openClaudeCodeLauncher); }); From d3ca902ccf932718fbeadc9c6ce57dd2c4154828 Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Sat, 16 May 2026 15:53:19 -0400 Subject: [PATCH 2/2] fix(launcher): gate Coding-Agent tiles on CLI availability + add Codex Mehmet pointed out that the tiles added in this PR ignored the per-tile availability check: with only the Claude CLI installed, the opencode / Pi / GitHub Copilot CLI tiles still showed up in the launcher (and the same was true of the older Claude tile, but Mehmet couldn't see it because his Claude CLI was present). Root cause: JL's `Launcher` widget iterates every item in its model and renders one card per entry. The backing command's `isVisible` only gates the *command palette*, not the launcher tile. So calling `launcher.add(...)` unconditionally and trusting `isVisible: () => config.isAvailable()` to hide the card was a no-op. Fix: introduce `syncLauncherEntry(commandId, options, isAvailable)` that adds the launcher item when `isAvailable()` returns true and disposes the entry's `IDisposable` handle when it flips back. The sync runs on initial activate and again on every `NBIAPI.configChanged`, so a capabilities round-trip (or a future CLI-install hot reload) makes the right tiles appear and disappear without a page refresh. Applied to the Claude tile too, not just the new ones, so the gating behavior is consistent. Also added the OpenAI Codex CLI per Mehmet's other comment on this PR: a `codex_cli_available` capability flag (mirroring the existing four), `isCodexCliAvailable` getter, `NBI_CODEX_CLI_PATH` env override, and a launcher tile using `terminalIcon` as the first-phase placeholder (brand icon is a follow-up alongside opencode and Pi). Verified with Playwright against a freshly built labextension. On the test machine Claude and Codex are on PATH, opencode / Pi / Copilot are not. After the fix the Coding Agent category renders exactly two tiles ("Claude Code" and "Codex"), confirming the gating works on both the new branch (5 tiles registered) and the previously-bugged Claude tile. --- notebook_intelligence/extension.py | 3 +- notebook_intelligence/util.py | 5 +++ src/api.ts | 4 ++ src/command-ids.ts | 1 + src/index.ts | 69 ++++++++++++++++++++++-------- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 4720d50b..b5bc5833 100644 --- a/notebook_intelligence/extension.py +++ b/notebook_intelligence/extension.py @@ -52,7 +52,7 @@ ) import notebook_intelligence.github_copilot as github_copilot from notebook_intelligence.built_in_toolsets import built_in_toolsets -from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path +from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path, resolve_codex_cli_path from notebook_intelligence.context_factory import RuleContextFactory from notebook_intelligence.skillset import SKILL_NAME_REGEX @@ -489,6 +489,7 @@ def is_provider_enabled(provider_id: str) -> bool: "opencode_cli_available": resolve_opencode_cli_path() is not None, "pi_cli_available": resolve_pi_cli_path() is not None, "github_copilot_cli_available": resolve_copilot_cli_path() is not None, + "codex_cli_available": resolve_codex_cli_path() is not None, "default_chat_mode": nbi_config.default_chat_mode, "chat_feedback_enabled": self.enable_chat_feedback, # Single source of truth lives on each domain's base handler so diff --git a/notebook_intelligence/util.py b/notebook_intelligence/util.py index ef76c5a0..9ffc0f90 100644 --- a/notebook_intelligence/util.py +++ b/notebook_intelligence/util.py @@ -61,6 +61,11 @@ def resolve_copilot_cli_path() -> Optional[str]: return _resolve_cli_path("copilot", "NBI_GITHUB_COPILOT_CLI_PATH") +def resolve_codex_cli_path() -> Optional[str]: + """OpenAI Codex CLI. NBI_CODEX_CLI_PATH overrides; else `codex` on PATH.""" + return _resolve_cli_path("codex", "NBI_CODEX_CLI_PATH") + + def invalidate_cli_cache(name: Optional[str] = None) -> None: """Clear the memoized CLI-path cache. Pass a name to invalidate just one.""" if name is None: diff --git a/src/api.ts b/src/api.ts index 6b7053c6..d3a0f5bf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -368,6 +368,10 @@ export class NBIConfig { return this.capabilities.github_copilot_cli_available === true; } + get isCodexCliAvailable(): boolean { + return this.capabilities.codex_cli_available === true; + } + get chatFeedbackEnabled(): boolean { return this.capabilities.chat_feedback_enabled === true; } diff --git a/src/command-ids.ts b/src/command-ids.ts index b68e4e88..9b849aab 100644 --- a/src/command-ids.ts +++ b/src/command-ids.ts @@ -61,4 +61,5 @@ export namespace CommandIDs { export const openPiLauncher = 'notebook-intelligence:open-pi-launcher'; export const openGitHubCopilotCliLauncher = 'notebook-intelligence:open-github-copilot-cli-launcher'; + export const openCodexLauncher = 'notebook-intelligence:open-codex-launcher'; } diff --git a/src/index.ts b/src/index.ts index f75aa248..0568fd1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { Menu, Panel, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; import { IStatusBar } from '@jupyterlab/statusbar'; import { ILauncher } from '@jupyterlab/launcher'; +import { IDisposable } from '@lumino/disposable'; import React from 'react'; import { ReactWidget } from '@jupyterlab/apputils'; import { LauncherPicker } from './components/launcher-picker'; @@ -1315,13 +1316,40 @@ const plugin: JupyterFrontEndPlugin = { } }); - if (launcher) { - launcher.add({ - command: CommandIDs.openClaudeCodeLauncher, - category: 'Coding Agent', - rank: -1 - }); - } + // Add or dispose a launcher entry based on a live availability check. + // The launcher renders every item in its model regardless of the + // backing command's `isVisible`, so gating tile visibility requires + // adding only when available and disposing when not. Re-evaluates on + // every NBIAPI.configChanged so a late capabilities load (or a + // future hot-reload of the CLI on PATH) takes effect without a + // browser refresh. + const syncLauncherEntry = ( + commandId: string, + itemOptions: Omit, + isAvailable: () => boolean + ) => { + if (!launcher) { + return; + } + let entry: IDisposable | null = null; + const sync = () => { + const available = isAvailable(); + if (available && !entry) { + entry = launcher.add({ command: commandId, ...itemOptions }); + } else if (!available && entry) { + entry.dispose(); + entry = null; + } + }; + sync(); + NBIAPI.configChanged.connect(sync); + }; + + syncLauncherEntry( + CommandIDs.openClaudeCodeLauncher, + { category: 'Coding Agent', rank: -1 }, + () => NBIAPI.config.isClaudeCliAvailable + ); // Additional coding-agent CLIs (issue #260). First-phase scope: detect // the binary on PATH (backend exposes `_cli_available`), show a @@ -1344,12 +1372,11 @@ const plugin: JupyterFrontEndPlugin = { launchCliInTerminal(config.cliCommand, defaultBrowser?.model.path); } }); - if (launcher) { - launcher.add({ - command: config.commandId, - category: 'Coding Agent' - }); - } + syncLauncherEntry( + config.commandId, + { category: 'Coding Agent' }, + config.isAvailable + ); NBIAPI.configChanged.connect(() => { app.commands.notifyCommandChanged(config.commandId); }); @@ -1382,10 +1409,18 @@ const plugin: JupyterFrontEndPlugin = { isAvailable: () => NBIAPI.config.isGitHubCopilotCliAvailable }); - // Refresh the Claude Code launcher tile's enabled/visible state when the - // user toggles Claude Code mode or installs the CLI. Without this, the - // tile keeps its initial-load decision until full reload. The other - // agent-CLI tiles are wired the same way inside registerAgentCliLauncher. + registerAgentCliLauncher({ + commandId: CommandIDs.openCodexLauncher, + label: 'Codex', + caption: 'Start an OpenAI Codex CLI session in a Jupyter terminal', + icon: terminalIcon, + cliCommand: 'codex', + isAvailable: () => NBIAPI.config.isCodexCliAvailable + }); + + // Refresh the Claude Code command's palette-visibility state when the + // user installs/uninstalls the CLI. The launcher tile is already gated + // via syncLauncherEntry; this is for the command palette only. NBIAPI.configChanged.connect(() => { app.commands.notifyCommandChanged(CommandIDs.openClaudeCodeLauncher); });