diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 109c9f95..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 +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 @@ -482,8 +482,14 @@ 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, + "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 b0238000..9ffc0f90 100644 --- a/notebook_intelligence/util.py +++ b/notebook_intelligence/util.py @@ -22,32 +22,56 @@ 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 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_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..d3a0f5bf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -356,6 +356,22 @@ 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 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 29f3d75d..9b849aab 100644 --- a/src/command-ids.ts +++ b/src/command-ids.ts @@ -56,4 +56,10 @@ 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'; + export const openCodexLauncher = 'notebook-intelligence:open-codex-launcher'; } diff --git a/src/index.ts b/src/index.ts index 87d8e90d..0568fd1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,12 +43,13 @@ 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'; 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'; @@ -1232,7 +1233,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 +1289,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,22 +1311,116 @@ 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); } } }); - 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 + // 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); + } }); - } + syncLauncherEntry( + config.commandId, + { category: 'Coding Agent' }, + config.isAvailable + ); + 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 + }); + + 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 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. + // 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); });