feat: show platform-specific Copilot CLI install instructions when CLI is missing

Replace the generic 'Install the copilot CLI' one-liner with a rich,
platform-tabbed installation guide. Auto-detects macOS/Windows/Linux and
shows the recommended install command (Homebrew, WinGet, or install script)
plus alternatives (npm). Includes copy-to-clipboard, auth step, and a
refresh button.

- Extend platform.ts with isWindows, isLinux, detectedPlatform
- Add cliInstallInstructions.ts with per-platform install data
- Add CliInstallGuide component in settings/ with platform tabs
- Integrate into CopilotStatusCard for copilot-cli-missing state
- Enhance WelcomePane with CliMissingCard showing quick-start command

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 12:34:01 +02:00
co-authored by Copilot
parent 30ff81009a
commit ebb506a359
5 changed files with 309 additions and 4 deletions
+10 -2
View File
@@ -16,6 +16,7 @@ import {
Loader2,
} from 'lucide-react';
import { CliInstallGuide } from '@renderer/components/settings/CliInstallGuide';
import type {
SidecarConnectionDiagnostics,
SidecarConnectionStatus,
@@ -391,8 +392,15 @@ export function CopilotStatusCard({
</div>
)}
{/* Action hint for non-ready states */}
{!isHealthy && config.actionLabel && (
{/* Installation guide for missing CLI */}
{connection.status === 'copilot-cli-missing' && (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
<CliInstallGuide isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
)}
{/* Action hint for other non-ready states */}
{!isHealthy && connection.status !== 'copilot-cli-missing' && config.actionLabel && (
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] px-3 py-2.5">
<div className="mt-0.5 shrink-0">{config.actionIcon}</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">{config.actionLabel}</p>
+35 -2
View File
@@ -1,7 +1,9 @@
import { CheckCircle2, Circle, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
import { CheckCircle2, Circle, Download, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
import { motion } from 'motion/react';
import type { SidecarConnectionStatus } from '@shared/contracts/sidecar';
import { detectedPlatform } from '@renderer/lib/platform';
import { getInstallInfoForPlatform } from '@renderer/lib/cliInstallInstructions';
import appIconUrl from '../../../assets/icons/icon.png';
interface WelcomePaneProps {
@@ -71,6 +73,34 @@ function SetupStep({ label, done, active }: SetupStepProps) {
);
}
function CliMissingCard({ onOpenSettings }: { onOpenSettings: () => void }) {
const info = getInstallInfoForPlatform(detectedPlatform);
const recommended = info.methods.find((m) => m.recommended) ?? info.methods[0];
return (
<button
type="button"
onClick={onOpenSettings}
className="group flex w-full cursor-pointer items-start gap-4 rounded-xl border border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] px-5 py-4 text-left shadow-[0_0_24px_rgba(36,92,249,0.1)] backdrop-blur-sm transition-all duration-200 hover:shadow-[0_0_32px_rgba(36,92,249,0.15)]"
>
<div className="brand-gradient-bg flex size-9 shrink-0 items-center justify-center rounded-full">
<Download className="size-4 text-white" />
</div>
<div className="min-w-0 space-y-1.5">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
Install the Copilot CLI
</span>
<code className="block truncate rounded-md bg-[var(--color-surface-1)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
{recommended.command}
</code>
<span className="block text-[11px] text-[var(--color-text-muted)]">
View full instructions for {info.displayName}
</span>
</div>
</button>
);
}
export function WelcomePane({
hasProjects,
connectionStatus,
@@ -165,7 +195,10 @@ export function WelcomePane({
{/* Action cards */}
<motion.div {...fadeUp(isFirstRun && !allDone ? 0.2 : 0.16)} className="flex w-full flex-col gap-2.5">
{/* Primary CTA adapts to state */}
{!isConnected && (
{connectionStatus === 'copilot-cli-missing' && (
<CliMissingCard onOpenSettings={onOpenSettings} />
)}
{!isConnected && connectionStatus !== 'copilot-cli-missing' && (
<ActionCard
icon={<Zap className="size-4 text-white" />}
title="Connect GitHub Copilot"
@@ -0,0 +1,209 @@
import { useState, useCallback } from 'react';
import { Check, Copy, ChevronRight, KeyRound, Sparkles } from 'lucide-react';
import { detectedPlatform, type DetectedPlatform } from '@renderer/lib/platform';
import {
installInstructions,
authCommand,
type PlatformInstallInfo,
type InstallMethod,
} from '@renderer/lib/cliInstallInstructions';
interface CliInstallGuideProps {
onRefresh: () => void;
isRefreshing: boolean;
}
function PlatformTab({
info,
active,
onClick,
}: {
info: PlatformInstallInfo;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`relative rounded-md px-3 py-1.5 text-[11px] font-semibold tracking-wide transition-all duration-200 ${
active
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
aria-pressed={active}
>
{info.displayName}
</button>
);
}
function CommandBlock({
method,
index,
}: {
method: InstallMethod;
index: number;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(method.command);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}, [method.command]);
return (
<div
className="group/cmd space-y-1.5"
style={{ animationDelay: `${index * 60}ms` }}
>
{/* Method label row */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{method.label}
</span>
{method.recommended && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent)]/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-[var(--color-accent)]">
<Sparkles className="size-2.5" />
Recommended
</span>
)}
</div>
{/* Command block */}
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
<ChevronRight className="size-3 shrink-0 text-[var(--color-accent)]/60" />
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
{method.command}
</code>
</div>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
title="Copy command"
aria-label={`Copy command: ${method.command}`}
>
{copied ? (
<Check className="size-3 text-[var(--color-status-success)]" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
</div>
);
}
export function CliInstallGuide({ onRefresh, isRefreshing }: CliInstallGuideProps) {
const [activePlatform, setActivePlatform] = useState<DetectedPlatform>(detectedPlatform);
const activeInfo = installInstructions.find((i) => i.platform === activePlatform)!;
return (
<div className="space-y-4">
{/* Step 1: Install */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
1
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Install the Copilot CLI
</span>
</div>
{/* Platform tabs */}
<div className="flex items-center gap-1 rounded-lg bg-[var(--color-surface-2)] p-1">
{installInstructions.map((info) => (
<PlatformTab
key={info.platform}
active={activePlatform === info.platform}
info={info}
onClick={() => setActivePlatform(info.platform)}
/>
))}
</div>
{/* Commands for active platform */}
<div className="space-y-3">
{activeInfo.methods.map((method, i) => (
<CommandBlock key={method.label} index={i} method={method} />
))}
</div>
</div>
{/* Step 2: Authenticate */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
2
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Sign in to GitHub
</span>
</div>
<AuthCommandBlock />
</div>
{/* Step 3: Refresh */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
3
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Refresh connection
</span>
</div>
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/15 disabled:opacity-50"
>
{isRefreshing ? 'Checking…' : 'Check connection'}
</button>
</div>
</div>
);
}
function AuthCommandBlock() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(authCommand);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}, []);
return (
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
<KeyRound className="size-3 shrink-0 text-[var(--color-accent)]/60" />
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
{authCommand}
</code>
</div>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
title="Copy command"
aria-label="Copy authentication command"
>
{copied ? (
<Check className="size-3 text-[var(--color-status-success)]" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
);
}
@@ -0,0 +1,48 @@
import type { DetectedPlatform } from './platform';
export interface InstallMethod {
label: string;
command: string;
recommended?: boolean;
}
export interface PlatformInstallInfo {
platform: DetectedPlatform;
displayName: string;
methods: InstallMethod[];
}
export const installInstructions: PlatformInstallInfo[] = [
{
platform: 'macos',
displayName: 'macOS',
methods: [
{ label: 'Homebrew', command: 'brew install copilot-cli', recommended: true },
{ label: 'Install script', command: 'curl -fsSL https://gh.io/copilot-install | bash' },
{ label: 'npm', command: 'npm install -g @github/copilot' },
],
},
{
platform: 'windows',
displayName: 'Windows',
methods: [
{ label: 'WinGet', command: 'winget install GitHub.Copilot', recommended: true },
{ label: 'npm', command: 'npm install -g @github/copilot' },
],
},
{
platform: 'linux',
displayName: 'Linux',
methods: [
{ label: 'Install script', command: 'curl -fsSL https://gh.io/copilot-install | bash', recommended: true },
{ label: 'Homebrew', command: 'brew install copilot-cli' },
{ label: 'npm', command: 'npm install -g @github/copilot' },
],
},
];
export const authCommand = 'copilot auth login';
export function getInstallInfoForPlatform(platform: DetectedPlatform): PlatformInstallInfo {
return installInstructions.find((i) => i.platform === platform)!;
}
+7
View File
@@ -1 +1,8 @@
export type DetectedPlatform = 'macos' | 'windows' | 'linux';
export const isMac = navigator.platform.startsWith('Mac');
export const isWindows = navigator.platform.startsWith('Win');
export const isLinux = !isMac && !isWindows;
export const detectedPlatform: DetectedPlatform =
isMac ? 'macos' : isWindows ? 'windows' : 'linux';