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.';
+}