feat(app): show tmux install guidance on dashboard
This commit is contained in:
parent
30fb2501d3
commit
cf8df6b306
10 changed files with 409 additions and 0 deletions
|
|
@ -78,6 +78,7 @@ import {
|
|||
registerTerminalHandlers,
|
||||
removeTerminalHandlers,
|
||||
} from './terminal';
|
||||
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
|
||||
import {
|
||||
initializeUpdaterHandlers,
|
||||
registerUpdaterHandlers,
|
||||
|
|
@ -241,6 +242,7 @@ export function initializeIpcHandlers(
|
|||
if (ptyTerminal) {
|
||||
registerTerminalHandlers(ipcMain);
|
||||
}
|
||||
registerTmuxHandlers(ipcMain);
|
||||
if (httpServerDeps) {
|
||||
registerHttpServerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -279,6 +281,7 @@ export function removeIpcHandlers(): void {
|
|||
removeScheduleHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeTmuxHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
removeExtensionHandlers(ipcMain);
|
||||
removeSkillsHandlers(ipcMain);
|
||||
|
|
|
|||
138
src/main/ipc/tmux.ts
Normal file
138
src/main/ipc/tmux.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { TMUX_GET_STATUS } from '@preload/constants/ipcChannels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:tmux');
|
||||
|
||||
let cachedStatus: { value: TmuxStatus; at: number } | null = null;
|
||||
let statusInFlight: Promise<TmuxStatus> | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
function mapPlatform(platform: NodeJS.Platform): TmuxPlatform {
|
||||
if (platform === 'darwin' || platform === 'linux' || platform === 'win32') {
|
||||
return platform;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function execFileAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeout: number
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBinaryPath(platform: TmuxPlatform): Promise<string | null> {
|
||||
const locator = platform === 'win32' ? 'where' : 'which';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(locator, ['tmux'], 2_000);
|
||||
const firstLine = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
return firstLine ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeTmuxStatus(): Promise<TmuxStatus> {
|
||||
const platform = mapPlatform(process.platform);
|
||||
const nativeSupported = platform === 'darwin' || platform === 'linux';
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('tmux', ['-V'], 3_000);
|
||||
const version = (stdout || stderr).trim() || null;
|
||||
const binaryPath = await resolveBinaryPath(platform);
|
||||
return {
|
||||
available: true,
|
||||
version,
|
||||
binaryPath,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const missing =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
((error as { code?: string }).code === 'ENOENT' ||
|
||||
(error as { code?: string }).code === 'ENOEXEC');
|
||||
|
||||
if (missing) {
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`tmux status check failed: ${message}`);
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetStatus(_event: IpcMainInvokeEvent): Promise<IpcResult<TmuxStatus>> {
|
||||
try {
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
return { success: true, data: cachedStatus.value };
|
||||
}
|
||||
|
||||
if (!statusInFlight) {
|
||||
statusInFlight = computeTmuxStatus()
|
||||
.then((status) => {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
statusInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error('Error in tmux:getStatus:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTmuxHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TMUX_GET_STATUS, handleGetStatus);
|
||||
logger.info('tmux handlers registered');
|
||||
}
|
||||
|
||||
export function removeTmuxHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TMUX_GET_STATUS);
|
||||
logger.info('tmux handlers removed');
|
||||
}
|
||||
|
|
@ -429,6 +429,9 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
|
|||
/** Invalidate cached CLI status (forces fresh check on next getStatus) */
|
||||
export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus';
|
||||
|
||||
/** Get current tmux runtime availability for dashboard diagnostics */
|
||||
export const TMUX_GET_STATUS = 'tmux:getStatus';
|
||||
|
||||
// =============================================================================
|
||||
// Terminal API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_PROGRESS,
|
||||
TMUX_GET_STATUS,
|
||||
CONTEXT_CHANGED,
|
||||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
|
|
@ -290,6 +291,7 @@ import type {
|
|||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdateSchedulePatch,
|
||||
|
|
@ -1368,6 +1370,12 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
},
|
||||
|
||||
tmux: {
|
||||
getStatus: async (): Promise<TmuxStatus> => {
|
||||
return invokeIpcWithResult<TmuxStatus>(TMUX_GET_STATUS);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== Terminal API =====
|
||||
terminal: {
|
||||
spawn: (options?: PtySpawnOptions) => invokeIpcWithResult<string>(TERMINAL_SPAWN, options),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ import type {
|
|||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdaterAPI,
|
||||
|
|
@ -1086,6 +1088,18 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
},
|
||||
};
|
||||
|
||||
tmux: TmuxAPI = {
|
||||
getStatus: async (): Promise<TmuxStatus> => ({
|
||||
available: true,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform: 'unknown',
|
||||
nativeSupported: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal (not available in browser mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
|
||||
import { CliStatusBanner } from './CliStatusBanner';
|
||||
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
||||
import { TmuxStatusBanner } from './TmuxStatusBanner';
|
||||
|
||||
import type { RepositoryGroup } from '@renderer/types/data';
|
||||
import type { TeamSummary } from '@shared/types';
|
||||
|
|
@ -837,6 +838,7 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
|
||||
{/* CLI Status Banner */}
|
||||
<CliStatusBanner />
|
||||
<TmuxStatusBanner />
|
||||
|
||||
{/* Team select + Search */}
|
||||
<div className="mb-12 flex items-center justify-center gap-3">
|
||||
|
|
|
|||
219
src/renderer/components/dashboard/TmuxStatusBanner.tsx
Normal file
219
src/renderer/components/dashboard/TmuxStatusBanner.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react';
|
||||
|
||||
import type { TmuxStatus } from '@shared/types';
|
||||
|
||||
const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing';
|
||||
|
||||
type BannerState =
|
||||
| { loading: true; status: null; error: null }
|
||||
| { loading: false; status: TmuxStatus; error: null }
|
||||
| { loading: false; status: null; error: string };
|
||||
|
||||
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
|
||||
|
||||
function PlatformInstallMatrix(): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-3">
|
||||
<div
|
||||
className="rounded-md border px-3 py-2"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.18)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
macOS
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<div>Homebrew</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">brew install tmux</code>
|
||||
<div>MacPorts</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">port install tmux</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md border px-3 py-2"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.18)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Linux
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">apt install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">dnf install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">yum install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">zypper install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">pacman -S tmux</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md border px-3 py-2"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.18)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Windows
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>В official tmux wiki нет native Windows install command.</p>
|
||||
<p>
|
||||
Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux
|
||||
команд выше, например <code className="font-mono">apt install tmux</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrimaryDetail(status: TmuxStatus): string {
|
||||
if (status.platform === 'darwin') {
|
||||
return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.';
|
||||
}
|
||||
if (status.platform === 'linux') {
|
||||
return 'На Linux команда зависит от дистрибутива: apt, dnf, yum, zypper или pacman.';
|
||||
}
|
||||
if (status.platform === 'win32') {
|
||||
return 'На Windows у official tmux wiki нет native installer; safest путь — WSL и установка tmux внутри Linux-дистрибутива.';
|
||||
}
|
||||
return 'Поставь tmux через пакетный менеджер своей ОС.';
|
||||
}
|
||||
|
||||
export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [state, setState] = useState<BannerState>(INITIAL_STATE);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setState(
|
||||
(prev) =>
|
||||
({
|
||||
loading: true,
|
||||
status: prev.status,
|
||||
error: null,
|
||||
}) as BannerState
|
||||
);
|
||||
|
||||
try {
|
||||
const status = await api.tmux.getStatus();
|
||||
setState({ loading: false, status, error: null });
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
status: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to check tmux status',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
void fetchStatus();
|
||||
}, [fetchStatus, isElectron]);
|
||||
|
||||
if (!isElectron) return null;
|
||||
if (state.loading && !state.status) return null;
|
||||
|
||||
if (state.error && !state.status) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: '#fbbf24' }} />
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: '#fbbf24' }}>
|
||||
Failed to check tmux availability
|
||||
</div>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{state.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void fetchStatus()}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.status || state.status.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Wrench className="mt-0.5 size-4 shrink-0" style={{ color: '#fbbf24' }} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium" style={{ color: '#fbbf24' }}>
|
||||
tmux is not installed
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 text-xs leading-relaxed"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся
|
||||
на более тяжёлом in-process пути. {getPrimaryDetail(state.status)}
|
||||
</p>
|
||||
{state.status.error && (
|
||||
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||
Last check error: {state.status.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={() => void fetchStatus()}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${state.loading ? 'animate-spin' : ''}`} />
|
||||
Re-check
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void api.openExternal(OFFICIAL_TMUX_INSTALL_URL)}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open guide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlatformInstallMatrix />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -81,6 +81,7 @@ import type {
|
|||
UpdateKanbanPatch,
|
||||
} from './team';
|
||||
import type { TerminalAPI } from './terminal';
|
||||
import type { TmuxAPI } from './tmux';
|
||||
import type { WaterfallData } from './visualization';
|
||||
import type {
|
||||
ConversationGroup,
|
||||
|
|
@ -823,6 +824,9 @@ export interface ElectronAPI {
|
|||
// CLI Installer API
|
||||
cliInstaller: CliInstallerAPI;
|
||||
|
||||
// tmux runtime diagnostics API
|
||||
tmux: TmuxAPI;
|
||||
|
||||
// Embedded Terminal API (xterm.js + node-pty)
|
||||
terminal: TerminalAPI;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,3 +44,6 @@ export type * from './editor';
|
|||
|
||||
// Re-export Extension Store types (inferCapabilities is re-exported from extensionNormalizers)
|
||||
export type * from './extensions';
|
||||
|
||||
// Re-export tmux runtime status types
|
||||
export type * from './tmux';
|
||||
|
|
|
|||
15
src/shared/types/tmux.ts
Normal file
15
src/shared/types/tmux.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown';
|
||||
|
||||
export interface TmuxStatus {
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
binaryPath: string | null;
|
||||
platform: TmuxPlatform;
|
||||
nativeSupported: boolean;
|
||||
checkedAt: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TmuxAPI {
|
||||
getStatus: () => Promise<TmuxStatus>;
|
||||
}
|
||||
Loading…
Reference in a new issue