Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions notebook_intelligence/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
60 changes: 42 additions & 18 deletions notebook_intelligence/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
16 changes: 16 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/command-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
121 changes: 108 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1232,7 +1233,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {

// 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<void> => {
Expand Down Expand Up @@ -1288,7 +1289,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
const cmd = session.cwd
? `cd ${session.cwd} && claude --resume ${session.session_id}`
: `claude --resume ${session.session_id}`;
launchClaudeInTerminal(cmd);
launchCliInTerminal(cmd);
}
});
}
Expand All @@ -1310,22 +1311,116 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
// 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<ILauncher.IItemOptions, 'command'>,
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 `<agent>_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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAvailable doesn't seem to have effect. I have only Claude CLI available but all tiles are visible & enabled.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API response looks correct.

"claude_cli_available": true,
    "opencode_cli_available": false,
    "pi_cli_available": false,
    "github_copilot_cli_available": false,

});

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);
});
Expand Down
Loading