import * as React from 'react';
import type { CommandRegistry } from '@lumino/commands';
import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
import { Poll } from '@lumino/polling';
import type { IDocumentWidget } from '@jupyterlab/docregistry';
import type { TranslationBundle } from '@jupyterlab/translation';
import {
LabIcon,
ReactWidget,
consoleIcon,
notebookIcon,
refreshIcon,
terminalIcon
} from '@jupyterlab/ui-components';
import type { Widget } from '@lumino/widgets';
import type { IAgentSessions } from '../agentSessions';
import { expandStatusFiles, status } from '../git/api';
import {
CommandIDs as GitCommandIDs,
CommandArguments as GitCommandArguments
} from '../git/commands';
import type {
FileChangeStatus,
IFileChange,
IGitStatusResult
} from '../git/tokens';
import type { IAgent } from './agents';
import { launchInTerminal } from './commands';
import type { IEditor } from './editors';
import { agentCommandId } from './tokens';
/**
* Public construction options for the launcher dashboard widget. The
* plugin supplies an `onAgentLaunch` callback that performs the same
* shell placement the stock JupyterLab launcher does (swap the launcher
* tab for the freshly created terminal). Keeping it as an opaque
* callback means the widget itself does not import `app.shell`.
*/
interface ILauncherDashboardOptions {
commands: CommandRegistry;
/**
* The agent list to render. Already filtered by availability + sorted by
* rank; the widget renders them as-is.
*/
agents: IAgent[];
/**
* The terminal editor to offer in the "Open" section (Neovim preferred over
* Vim), or `null` when neither is installed. Resolved by the plugin from the
* same availability probe that filters the agents.
*/
editor: IEditor | null;
/**
* Shared launch-tag registry. When the editor tile launches, it records the
* session here so the terminals panel badges it with the editor's logo right
* away, the same as agent launches do. `null` when the provider plugin is
* unavailable.
*/
agentSessions: IAgentSessions | null;
/**
* Called after an agent command has returned its top-level widget. The
* plugin uses this to swap the new terminal into the launcher's tab.
*/
onAgentLaunch: (item: Widget) => void;
/**
* The git server-relative repo path used by the changes section. Empty
* string means "use the JupyterLab server's root and let git resolve the
* enclosing repo" — same convention as the git panel.
*/
repoPath: string;
/**
* The cwd forwarded to `terminal:create-new` when an agent is launched.
* Empty string lets the terminal extension default to the server root.
*/
cwd: string;
/**
* The translation bundle used to localize the dashboard's user-facing
* strings.
*/
trans: TranslationBundle;
}
/**
* Lumino host for the React-based launcher dashboard. Mirrors the
* `ReactWidget` pattern used by the git panel so the rest of the plugin
* (layout placement, signals, lifecycle) stays consistent.
*/
export class LauncherDashboard extends ReactWidget {
constructor(options: ILauncherDashboardOptions) {
super();
this.addClass('jp-xtralab-Launcher');
this._options = options;
}
protected render(): React.ReactElement {
return ;
}
private _options: ILauncherDashboardOptions;
}
interface IGitState {
loading: boolean;
result: IGitStatusResult | null;
error: string | null;
}
/**
* Refresh interval for the changes list. A bit slower than the git panel
* (which sits in the sidebar and is always visible) — the launcher tab is
* only opened intentionally, and the grid does not need to be live.
*/
const CHANGES_POLL_INTERVAL_MS = 8000;
/**
* Upper bound on the exponential backoff when the status request fails
* repeatedly. Matches the rest of the plugin's polls so behavior is
* consistent across the dashboard, the git panel, and the file tree.
*/
const CHANGES_POLL_MAX_MS = 300_000;
function LauncherDashboardComponent(
props: ILauncherDashboardOptions
): React.ReactElement {
const {
commands,
agents,
editor,
agentSessions,
onAgentLaunch,
repoPath,
cwd,
trans
} = props;
const [prompt, setPrompt] = React.useState('');
const [git, setGit] = React.useState({
loading: true,
result: null,
error: null
});
const refresh = React.useCallback(async () => {
try {
const result = await status(repoPath);
setGit({ loading: false, result, error: null });
} catch (error) {
setGit({
loading: false,
result: null,
error: error instanceof Error ? error.message : String(error)
});
}
}, [repoPath]);
// Driven by a Lumino `Poll` so the dashboard stops hitting the git
// endpoint while the JupyterLab tab is hidden, and so repeated
// failures back off exponentially instead of pegging the 8s cadence.
React.useEffect(() => {
const poll = new Poll({
name: '@xtralab/launcher:gitChanges',
factory: () => refresh(),
frequency: {
interval: CHANGES_POLL_INTERVAL_MS,
backoff: true,
max: CHANGES_POLL_MAX_MS
},
standby: 'when-hidden'
});
return () => {
poll.dispose();
};
}, [refresh]);
const launch = React.useCallback(
async (agent: IAgent) => {
const args: Record = { cwd };
const trimmed = prompt.trim();
if (trimmed.length > 0 && agent.promptArgs !== undefined) {
args.prompt = trimmed;
}
const result = (await commands.execute(
agentCommandId(agent.id),
args
)) as Widget | undefined;
if (result) {
onAgentLaunch(result);
}
},
[commands, cwd, prompt, onAgentLaunch]
);
const launchTerminal = React.useCallback(async () => {
const result = (await commands.execute('terminal:create-new', {
cwd
})) as Widget | undefined;
if (result) {
onAgentLaunch(result);
}
}, [commands, cwd, onAgentLaunch]);
const launchNotebook = React.useCallback(async () => {
// Open a fresh notebook with no kernel attached — equivalent to clicking
// "No Kernel" in the Select Kernel dialog. `notebook:create-new` always
// routes through that dialog when no kernelName is given, so drive the
// underlying docmanager steps directly and pass `shouldStart: false` to
// suppress kernel selection. The user picks a kernel from the notebook
// toolbar when they're ready.
const file = (await commands.execute('docmanager:new-untitled', {
path: cwd,
type: 'notebook'
})) as { path: string } | undefined;
if (!file) {
return;
}
const result = (await commands.execute('docmanager:open', {
path: file.path,
factory: 'Notebook',
kernelPreference: { shouldStart: false, canStart: true }
})) as IDocumentWidget | undefined;
if (result) {
// Match `notebook:create-new`'s post-open marker so the first manual
// save offers a rename instead of overwriting the auto-generated name.
result.isUntitled = true;
onAgentLaunch(result);
}
}, [commands, cwd, onAgentLaunch]);
const launchConsole = React.useCallback(async () => {
const result = (await commands.execute('console:create', {
cwd
})) as Widget | undefined;
if (result) {
onAgentLaunch(result);
}
}, [commands, cwd, onAgentLaunch]);
const launchEditor = React.useCallback(async () => {
if (!editor) {
return;
}
// An editor is "open a terminal and type its command" — the agent launch
// minus the prompt — so it shares `launchInTerminal` rather than the native
// `terminal:create-new` call the tiles above use, and tags the session the
// same way so the terminals panel badges it with the editor's logo.
const result = await launchInTerminal(commands, {
cwd,
invocation: editor.command,
label: editor.label,
onSession: name => agentSessions?.set(name, editor.command)
});
onAgentLaunch(result);
}, [commands, cwd, editor, agentSessions, onAgentLaunch]);
const openDiff = React.useCallback(
(change: IFileChange) => {
const args: GitCommandArguments.IOpenDiff = { repoPath, change };
void commands.execute(
GitCommandIDs.openDiff,
args as unknown as ReadonlyPartialJSONObject
);
},
[commands, repoPath]
);
return (
void refresh()}
trans={trans}
/>
);
}
function ChangesSection(props: {
git: IGitState;
onOpen: (change: IFileChange) => void;
onRefresh: () => void;
trans: TranslationBundle;
}): React.ReactElement {
const { git, onOpen, onRefresh, trans } = props;
const files = git.result ? expandStatusFiles(git.result.files) : [];
// Native `` rather than a hand-rolled toggle: we get the
// disclosure triangle, keyboard handling, and aria-expanded semantics
// for free, and the user's open/closed choice is tab-local state that
// we don't need to persist.
return (
{trans.__('Changes')}
{files.length > 0 && (
{files.length}
)}
);
}
function statusBadge(status: FileChangeStatus): string {
switch (status) {
case 'modified':
return 'M';
case 'added':
return 'A';
case 'deleted':
return 'D';
case 'renamed':
return 'R';
case 'untracked':
return 'U';
case 'unmerged':
return '!';
case 'typechange':
return 'T';
default:
return '?';
}
}
function AgentSection(props: {
agents: IAgent[];
prompt: string;
onPromptChange: (value: string) => void;
onLaunch: (agent: IAgent) => void;
trans: TranslationBundle;
}): React.ReactElement {
const { agents, prompt, onPromptChange, onLaunch, trans } = props;
const trimmed = prompt.trim();
const promptActive = trimmed.length > 0;
// The first prompt-capable agent is what Cmd/Ctrl+Enter fires; the hint
// text below the textarea names it so the keyboard shortcut target
// isn't a mystery.
const primaryAgent = agents.find(agent => agent.promptArgs !== undefined);
const hintId = 'jp-xtralab-Launcher-agent-hint';
return (
{trans.__('Start an agent')}
);
}
/**
* Launcher agents whose CLI has a clean, non-interactive `mcp add` that takes
* ` -- `. The other agents (Goose, OpenCode, Kiro, Mistral
* Vibe, Antigravity) register MCP through their own config files or UI, so
* there is no one-line command to surface for them.
*/
const MCP_ADD_AGENT_IDS = new Set(['claude', 'codex', 'copilot']);
/**
* The stdio proxy console script that bridges an agent to the MCP server.
*/
const MCP_PROXY_COMMAND = 'jupyter-server-mcp-proxy';
/**
* Copy `text` to the clipboard via a hidden, selected `