feat(app): show tmux install guidance on dashboard

This commit is contained in:
iliya 2026-04-06 15:02:11 +03:00
parent 30fb2501d3
commit cf8df6b306
10 changed files with 409 additions and 0 deletions

View file

@ -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
View 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');
}

View file

@ -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
// =============================================================================

View file

@ -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),

View file

@ -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)
// ---------------------------------------------------------------------------

View file

@ -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">

View 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>
);
};

View file

@ -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;

View file

@ -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
View 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>;
}