From 1062fe3b656a59d531f7d34d0a88731f99f259e5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:02:24 +0300 Subject: [PATCH] fix(tmux): tighten windows installer flow --- .../runtime/TmuxInstallerRunnerAdapter.ts | 46 ++++++-- .../TmuxInstallerRunnerAdapter.test.ts | 101 ++++++++++++++++++ .../installer/TmuxCommandRunner.ts | 13 ++- .../decodeInstallerProcessOutput.test.ts | 23 ++++ .../runtime/decodeInstallerProcessOutput.ts | 100 +++++++++++++++++ .../main/infrastructure/wsl/TmuxWslService.ts | 34 +----- .../adapters/TmuxInstallerBannerAdapter.ts | 39 +++++-- .../TmuxInstallerBannerAdapter.test.ts | 44 +++++++- .../renderer/ui/TmuxInstallerBannerView.tsx | 46 ++++++-- .../TmuxInstallerBannerView.test.tsx | 4 + .../renderer/utils/formatTmuxInstallerText.ts | 14 ++- 11 files changed, 401 insertions(+), 63 deletions(-) create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts index 5553a07d..8697e8db 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -15,6 +15,8 @@ import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructu const MAX_LOG_LINES = 400; const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch']; const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu'; +const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000; +const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6; class TmuxInstallCancelledError extends Error { constructor() { @@ -33,6 +35,7 @@ export class TmuxInstallerRunnerAdapter readonly #wslService: TmuxWslService; readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner; readonly #presenter: TmuxInstallerProgressPresenter; + readonly #sleep: (ms: number) => Promise; #cancelRequested = false; #snapshot: TmuxInstallerSnapshot = { phase: 'idle', @@ -55,7 +58,8 @@ export class TmuxInstallerRunnerAdapter commandRunner = new TmuxCommandRunner(), terminalSession = new TmuxInstallTerminalSession(), wslService = new TmuxWslService(), - windowsElevatedStepRunner = new WindowsElevatedStepRunner() + windowsElevatedStepRunner = new WindowsElevatedStepRunner(), + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) ) { this.#statusSource = statusSource; this.#presenter = presenter; @@ -64,6 +68,7 @@ export class TmuxInstallerRunnerAdapter this.#terminalSession = terminalSession; this.#wslService = wslService; this.#windowsElevatedStepRunner = windowsElevatedStepRunner; + this.#sleep = sleep; } getSnapshot(): TmuxInstallerSnapshot { @@ -412,15 +417,15 @@ export class TmuxInstallerRunnerAdapter inputPrompt: null, inputSecret: false, }); - const status = await this.#refreshStatus(); - if (!status.wsl?.distroName) { + const status = await this.#waitForWindowsDistroStatus(); + if (status.wsl?.rebootRequired) { this.#setSnapshot({ - phase: 'needs_manual_step', + phase: 'needs_restart', strategy: 'wsl', - message: 'WSL distro install still needs a manual step', + message: 'Restart Windows before continuing with tmux setup', detail: - status.wsl?.statusDetail ?? - 'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.', + status.wsl.statusDetail ?? + 'Windows still needs a restart before the installed WSL distro can be finalized.', error: null, canCancel: false, acceptsInput: false, @@ -429,6 +434,33 @@ export class TmuxInstallerRunnerAdapter }); return status; } + if (!status.wsl?.distroName) { + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Finish Ubuntu setup in WSL', + detail: + 'Ubuntu installation was started, but Windows has not exposed the distro to the app yet. Wait a moment, then click Re-check. If Ubuntu appears in the Start menu, open it once and complete the first Linux user setup.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + return status; + } + + async #waitForWindowsDistroStatus(): Promise { + let status = await this.#refreshStatus(); + for (let attempt = 0; attempt < WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS; attempt += 1) { + if (status.wsl?.distroName || status.wsl?.rebootRequired) { + return status; + } + await this.#sleep(WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS); + status = await this.#refreshStatus(); + } return status; } diff --git a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts index 5463e60f..b71579db 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts @@ -366,4 +366,105 @@ describe('TmuxInstallerRunnerAdapter', () => { expect(runner.getSnapshot().phase).toBe('waiting_for_external_step'); expect(runner.getSnapshot().message).toContain('Ubuntu'); }); + + it('keeps Windows distro install in an external-step state when Ubuntu is still being provisioned', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro is configured yet.', + }, + wslPreference: null, + }); + const pendingStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: initialStatus.autoInstall, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'WSL is available, but no Linux distribution is installed yet.', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is available, but no Linux distribution is installed yet.', + }, + wslPreference: { + preferredDistroName: 'Ubuntu', + source: 'persisted', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : pendingStatus; + }), + invalidateStatus: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not run while distro is still provisioning'); + }), + } as never, + { + run: vi.fn(async () => ({ exitCode: 0 })), + cancel: vi.fn(), + } as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + { + persistPreferredDistro: vi.fn(async () => undefined), + } as never, + { + runWslCoreInstall: vi.fn(), + } as never, + async () => undefined + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(statusCallCount).toBeGreaterThan(2); + expect(runner.getSnapshot().phase).toBe('waiting_for_external_step'); + expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup'); + expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check'); + }); }); diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts index 89e4c71a..72df349e 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -2,6 +2,8 @@ import { spawn } from 'node:child_process'; import { killProcessTree } from '@main/utils/childProcess'; +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + import type { ChildProcessByStdio } from 'node:child_process'; import type { Readable } from 'node:stream'; @@ -45,7 +47,8 @@ export class TmuxCommandRunner { return { push: (chunk: string): void => { pending += chunk; - const lines = pending.split(/\r?\n/); + const normalizedPending = pending.replace(/\r(?!\n)/g, '\n'); + const lines = normalizedPending.split('\n'); pending = lines.pop() ?? ''; for (const line of lines) { emitLine(line); @@ -64,8 +67,12 @@ export class TmuxCommandRunner { const stdoutWriter = createBufferedLineWriter(); const stderrWriter = createBufferedLineWriter(); - child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk))); - child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(chunk))); + child.stdout.on('data', (chunk: Buffer | string) => + stdoutWriter.push(decodeInstallerProcessOutput(chunk)) + ); + child.stderr.on('data', (chunk: Buffer | string) => + stderrWriter.push(decodeInstallerProcessOutput(chunk)) + ); child.on('error', (error) => { this.#activeChild = null; reject(error); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts new file mode 100644 index 00000000..60f310c4 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeInstallerProcessOutput } from '../decodeInstallerProcessOutput'; + +describe('decodeInstallerProcessOutput', () => { + it('decodes cp866 Windows console output with Cyrillic text', () => { + const buffer = Buffer.from([0x8f, 0xe0, 0xa8, 0xa2, 0xa5, 0xe2, 0x20, 0x8c, 0xa8, 0xe0]); + + expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет Мир'); + }); + + it('keeps utf8 output readable on non-Windows platforms', () => { + const buffer = Buffer.from('tmux is available\n', 'utf8'); + + expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\n'); + }); + + it('decodes utf16le output when it contains a BOM', () => { + const utf16le = Buffer.from('\uFEFFWSL core installation command completed.', 'utf16le'); + + expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain('WSL core installation'); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts new file mode 100644 index 00000000..18bf7faa --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts @@ -0,0 +1,100 @@ +const UTF8_DECODER = new TextDecoder('utf-8'); +const UTF16LE_DECODER = new TextDecoder('utf-16le'); +const IBM866_DECODER = new TextDecoder('ibm866'); +const WINDOWS_1251_DECODER = new TextDecoder('windows-1251'); + +export function decodeInstallerProcessOutput( + output: string | Buffer, + platform: NodeJS.Platform = process.platform +): string { + if (typeof output === 'string') { + return stripNulls(output); + } + if (output.length === 0) { + return ''; + } + + if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) { + return stripNulls(UTF16LE_DECODER.decode(output)); + } + + const utf8 = stripNulls(UTF8_DECODER.decode(output)); + if (platform !== 'win32') { + return utf8; + } + + const candidates = [ + utf8, + stripNulls(IBM866_DECODER.decode(output)), + stripNulls(WINDOWS_1251_DECODER.decode(output)), + ]; + + return candidates.slice(1).reduce( + (best, candidate) => (scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best), + candidates[0] ?? utf8 + ); +} + +function stripNulls(value: string): string { + return value.replace(/\0/g, ''); +} + +function hasUtf16LeBom(buffer: Buffer): boolean { + return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; +} + +function looksLikeUtf16Le(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 512); + if (sampleSize < 2) { + return false; + } + + let pairs = 0; + let nullsAtOddIndex = 0; + for (let i = 0; i + 1 < sampleSize; i += 2) { + pairs += 1; + if (buffer[i + 1] === 0) { + nullsAtOddIndex += 1; + } + } + + return pairs > 0 && nullsAtOddIndex / pairs >= 0.3; +} + +function scoreDecodedText(value: string): number { + let score = 0; + + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (char === '\uFFFD') { + score -= 25; + continue; + } + if (char === '\n' || char === '\r' || char === '\t') { + score += 0.5; + continue; + } + if (codePoint >= 0x20 && codePoint <= 0x7e) { + score += 1; + continue; + } + if ( + (codePoint >= 0x0400 && codePoint <= 0x04ff) || + (codePoint >= 0x0500 && codePoint <= 0x052f) + ) { + score += 4; + continue; + } + if (codePoint >= 0x2500 && codePoint <= 0x257f) { + score += 0.4; + continue; + } + if (codePoint < 0x20) { + score -= 5; + continue; + } + score += 0.1; + } + + return score; +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index 3741d985..a5dc3740 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -1,6 +1,8 @@ import { execFile } from 'node:child_process'; import path from 'node:path'; +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore'; import type { @@ -373,37 +375,7 @@ export class TmuxWslService { } #decodeOutput(output: string | Buffer): string { - if (typeof output === 'string') { - return output.replace(/\0/g, ''); - } - if (output.length === 0) { - return ''; - } - - const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe; - const decoded = - hasUtf16LeBom || this.#looksLikeUtf16Le(output) - ? output.toString('utf16le') - : output.toString('utf8'); - return decoded.replace(/\0/g, ''); - } - - #looksLikeUtf16Le(buffer: Buffer): boolean { - const sampleSize = Math.min(buffer.length, 512); - if (sampleSize < 2) { - return false; - } - - let pairs = 0; - let nullsAtOddIndex = 0; - for (let i = 0; i + 1 < sampleSize; i += 2) { - pairs += 1; - if (buffer[i + 1] === 0) { - nullsAtOddIndex += 1; - } - } - - return pairs > 0 && nullsAtOddIndex / pairs >= 0.3; + return decodeInstallerProcessOutput(output); } #parseWslDistros(stdout: string): string[] { diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index 16cb83d2..e927442e 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -3,6 +3,7 @@ import { formatTmuxInstallerProgress, formatTmuxInstallerTitle, formatTmuxLocationLabel, + formatTmuxOptionalBenefits, formatTmuxPlatformLabel, } from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText'; @@ -17,6 +18,7 @@ export interface TmuxInstallerBannerViewModel { loading: boolean; title: string; body: string; + benefitsBody: string | null; error: string | null; platformLabel: string | null; locationLabel: string | null; @@ -31,6 +33,8 @@ export interface TmuxInstallerBannerViewModel { installSupported: boolean; installDisabled: boolean; installLabel: string; + installButtonPrimary: boolean; + showRefreshButton: boolean; canCancel: boolean; acceptsInput: boolean; inputPrompt: string | null; @@ -60,6 +64,12 @@ export class TmuxInstallerBannerAdapter { const title = snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady ? 'tmux needs one more step' + : snapshot.message && + (snapshot.phase === 'pending_external_elevation' || + snapshot.phase === 'waiting_for_external_step' || + snapshot.phase === 'needs_restart' || + snapshot.phase === 'needs_manual_step') + ? snapshot.message : formatTmuxInstallerTitle(snapshot.phase); const primaryGuideUrl = status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; @@ -71,6 +81,8 @@ export class TmuxInstallerBannerAdapter { status?.effective.detail ?? status?.wsl?.statusDetail ?? 'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.'; + const benefitsBody = + status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null; const runtimeReadyLabel = status ? status.effective.runtimeReady ? 'Ready for persistent teammates' @@ -93,12 +105,27 @@ export class TmuxInstallerBannerAdapter { ? 'Install Ubuntu in WSL' : 'Install tmux in WSL' : formatInstallButtonLabel(snapshot.phase); + const installDisabled = + input.loading || + snapshot.phase === 'preparing' || + snapshot.phase === 'checking' || + snapshot.phase === 'requesting_privileges' || + snapshot.phase === 'pending_external_elevation' || + snapshot.phase === 'waiting_for_external_step' || + snapshot.phase === 'installing' || + snapshot.phase === 'verifying'; + const installButtonPrimary = + !installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry')); + const showRefreshButton = + !(status?.autoInstall.supported ?? false) || + (installLabel !== 'Re-check' && installLabel !== 'Re-check after restart'); return { visible, loading: input.loading, title, body, + benefitsBody, error: input.error ?? snapshot.error ?? status?.error ?? null, platformLabel: formatTmuxPlatformLabel(status?.platform ?? null), locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), @@ -111,16 +138,10 @@ export class TmuxInstallerBannerAdapter { manualHintsCollapsible, primaryGuideUrl, installSupported: status?.autoInstall.supported ?? false, - installDisabled: - input.loading || - snapshot.phase === 'preparing' || - snapshot.phase === 'checking' || - snapshot.phase === 'requesting_privileges' || - snapshot.phase === 'pending_external_elevation' || - snapshot.phase === 'waiting_for_external_step' || - snapshot.phase === 'installing' || - snapshot.phase === 'verifying', + installDisabled, installLabel, + installButtonPrimary, + showRefreshButton, canCancel: snapshot.canCancel, acceptsInput: snapshot.acceptsInput, inputPrompt: snapshot.inputPrompt, diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index 742807b4..d4be4a04 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -73,6 +73,9 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.manualHints).toHaveLength(1); expect(result.manualHintsCollapsible).toBe(false); expect(result.body).toContain('persistent teammate reliability'); + expect(result.benefitsBody).toContain('Optional, but recommended'); + expect(result.installButtonPrimary).toBe(true); + expect(result.showRefreshButton).toBe(true); }); it('prioritizes renderer errors and disables the install button while installing', () => { @@ -98,12 +101,14 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.title).toBe('Installing tmux'); expect(result.body).toBe('Renderer bridge failed'); + expect(result.benefitsBody).toContain('Optional, but recommended'); expect(result.error).toBe('Renderer bridge failed'); expect(result.installDisabled).toBe(true); expect(result.canCancel).toBe(true); expect(result.acceptsInput).toBe(false); expect(result.progressPercent).toBe(68); expect(result.logs).toEqual(['Downloading bottle...']); + expect(result.installButtonPrimary).toBe(false); }); it('exposes a manual guide url when auto install is unavailable', () => { @@ -143,8 +148,10 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.platformLabel).toBe('Windows'); expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install'); - expect(result.progressPercent).toBe(100); + expect(result.progressPercent).toBe(82); expect(result.manualHintsCollapsible).toBe(true); + expect(result.benefitsBody).toContain('With tmux in WSL'); + expect(result.showRefreshButton).toBe(true); }); it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => { @@ -174,6 +181,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.locationLabel).toBe('Host runtime'); expect(result.runtimeReadyLabel).toBe('Installed, but not active yet'); expect(result.versionLabel).toBe('tmux 3.4'); + expect(result.benefitsBody).toContain('With tmux in WSL'); }); it('exposes installer input metadata for interactive privilege flows', () => { @@ -259,5 +267,39 @@ describe('TmuxInstallerBannerAdapter', () => { expect(installWslResult.installLabel).toBe('Install WSL'); expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in WSL'); + expect(installWslResult.installButtonPrimary).toBe(true); + expect(installUbuntuResult.installButtonPrimary).toBe(true); + }); + + it('uses a specific Windows external-step message as the title', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Finish Ubuntu setup in WSL', + detail: + 'Ubuntu installation was started, but Windows has not exposed the distro to the app yet.', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.title).toBe('Finish Ubuntu setup in WSL'); + expect(result.progressPercent).toBe(48); + expect(result.installDisabled).toBe(true); + expect(result.showRefreshButton).toBe(true); }); }); diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 46e1b77f..d4cb725a 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -84,6 +84,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { > {viewModel.body}

+ {viewModel.benefitsBody && ( +
+ {viewModel.benefitsBody} +
+ )} {(viewModel.platformLabel || viewModel.locationLabel || viewModel.runtimeReadyLabel || @@ -155,8 +167,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { type="button" onClick={() => void install()} disabled={viewModel.installDisabled} - className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60" - style={{ borderColor: 'var(--color-border)' }} + className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${ + viewModel.installButtonPrimary ? 'hover:bg-emerald-500/20' : 'hover:bg-white/5' + }`} + style={ + viewModel.installButtonPrimary + ? { + borderColor: 'rgba(34, 197, 94, 0.75)', + backgroundColor: 'rgba(34, 197, 94, 0.16)', + color: '#dcfce7', + } + : { borderColor: 'var(--color-border)' } + } > {viewModel.installLabel} @@ -201,15 +223,17 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { : `Show setup steps (${viewModel.manualHints.length})`} )} - + {viewModel.showRefreshButton && ( + + )} diff --git a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx index f0ed57c6..14e67a3f 100644 --- a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -20,6 +20,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = { loading: false, title: 'tmux is not installed', body: 'WSL is available, but no Linux distribution is installed yet.', + benefitsBody: + 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.', error: null, platformLabel: 'Windows', locationLabel: null, @@ -45,6 +47,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = { installSupported: true, installDisabled: false, installLabel: 'Install Ubuntu in WSL', + installButtonPrimary: true, + showRefreshButton: true, canCancel: false, acceptsInput: false, inputPrompt: null, diff --git a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts index 1fe2eb66..8cd8294f 100644 --- a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -42,7 +42,7 @@ export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number | if (phase === 'verifying') return 90; if (phase === 'needs_restart') return 96; if (phase === 'completed') return 100; - if (phase === 'needs_manual_step') return 100; + if (phase === 'needs_manual_step') return 82; if (phase === 'error') return 100; if (phase === 'cancelled') return 0; return null; @@ -61,3 +61,15 @@ export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string if (location === 'wsl') return 'WSL runtime'; return null; } + +export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): string | null { + if (!platform) { + return null; + } + + if (platform === 'win32') { + return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.'; + } + + return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.'; +}