Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions src/cli/__tests__/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,9 @@ describe('runClaude OMC HUD behavior', () => {

runClaude('/tmp/cwd', [], 'test-session');

const calls = vi.mocked(execFileSync).mock.calls;
const tmuxCall = calls.find(([cmd]) => cmd === 'tmux');
expect(tmuxCall).toBeDefined();

const tmuxArgs = tmuxCall![1] as string[];
expect(tmuxArgs).not.toContain('split-window');
const tmuxCalls = vi.mocked(execFileSync).mock.calls.filter(([cmd]) => cmd === 'tmux');
expect(tmuxCalls.length).toBeGreaterThan(0);
expect(tmuxCalls.every(([, tmuxArgs]) => !(tmuxArgs as string[]).includes('split-window'))).toBe(true);
});
});

Expand All @@ -287,8 +284,8 @@ describe('runClaude outside-tmux — mouse scrolling (issue #890)', () => {
it('uses session-targeted mouse option instead of global (-t sessionName, not -g)', () => {
runClaude('/tmp', [], 'sid');

const calls = vi.mocked(execFileSync).mock.calls;
const tmuxCall = calls.find(([cmd]) => cmd === 'tmux');
const tmuxCalls = vi.mocked(execFileSync).mock.calls.filter(([cmd]) => cmd === 'tmux');
const tmuxCall = tmuxCalls.find(([, args]) => (args as string[])[0] === 'set-option');
expect(tmuxCall).toBeDefined();

const tmuxArgs = tmuxCall![1] as string[];
Expand All @@ -306,8 +303,9 @@ describe('runClaude outside-tmux — mouse scrolling (issue #890)', () => {
it('does not set terminal-overrides in tmux args', () => {
runClaude('/tmp', [], 'sid');

const calls = vi.mocked(execFileSync).mock.calls;
const tmuxCall = calls.find(([cmd]) => cmd === 'tmux');
const tmuxCalls = vi.mocked(execFileSync).mock.calls.filter(([cmd]) => cmd === 'tmux');
const tmuxCall = tmuxCalls.find(([, args]) => (args as string[])[0] === 'new-session');
expect(tmuxCall).toBeDefined();
const tmuxArgs = tmuxCall![1] as string[];

expect(tmuxArgs).not.toContain('terminal-overrides');
Expand All @@ -317,16 +315,57 @@ describe('runClaude outside-tmux — mouse scrolling (issue #890)', () => {
it('places mouse mode setup before attach-session', () => {
runClaude('/tmp', [], 'sid');

const calls = vi.mocked(execFileSync).mock.calls;
const tmuxCall = calls.find(([cmd]) => cmd === 'tmux');
const tmuxArgs = tmuxCall![1] as string[];
const tmuxCalls = vi.mocked(execFileSync).mock.calls
.map(([cmd, tmuxArgs]) => ({ cmd, tmuxArgs: tmuxArgs as string[] }))
.filter(({ cmd }) => cmd === 'tmux');

const mouseIdx = tmuxArgs.indexOf('mouse');
const attachIdx = tmuxArgs.indexOf('attach-session');
const mouseIdx = tmuxCalls.findIndex(({ tmuxArgs }) => tmuxArgs[0] === 'set-option');
const attachIdx = tmuxCalls.findIndex(({ tmuxArgs }) => tmuxArgs[0] === 'attach-session');
expect(mouseIdx).toBeGreaterThanOrEqual(0);
expect(attachIdx).toBeGreaterThanOrEqual(0);
expect(mouseIdx).toBeLessThan(attachIdx);
});

it('preserves a valid detached session when attach-session is interrupted', () => {
(execFileSync as ReturnType<typeof vi.fn>).mockImplementation((cmd: string, args: string[]) => {
if (cmd !== 'tmux') return Buffer.from('');
if (args[0] === 'attach-session') {
throw new Error('attach interrupted');
}
return Buffer.from('');
});

runClaude('/tmp', [], 'sid');

const tmuxCalls = vi.mocked(execFileSync).mock.calls
.filter(([cmd]) => cmd === 'tmux')
.map(([, tmuxArgs]) => tmuxArgs as string[]);

expect(tmuxCalls.map((tmuxArgs) => tmuxArgs[0])).toEqual([
'new-session',
'set-option',
'attach-session',
'has-session',
]);
expect(tmuxCalls.some((tmuxArgs) => tmuxArgs[0] === 'kill-session')).toBe(false);
expect(vi.mocked(execFileSync).mock.calls.find(([cmd]) => cmd === 'claude')).toBeUndefined();
expect(processExitSpy).not.toHaveBeenCalled();
});

it('falls back to direct launch when detached session creation fails', () => {
(execFileSync as ReturnType<typeof vi.fn>).mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'tmux' && args[0] === 'new-session') {
throw new Error('tmux launch failed');
}
return Buffer.from('');
});

runClaude('/tmp', ['--dangerously-skip-permissions'], 'sid');

const calls = vi.mocked(execFileSync).mock.calls;
expect(calls.filter(([cmd]) => cmd === 'tmux')).toHaveLength(1);
expect(calls.find(([cmd, args]) => cmd === 'claude' && (args as string[])[0] === '--dangerously-skip-permissions')).toBeDefined();
});
});

// ---------------------------------------------------------------------------
Expand Down
34 changes: 20 additions & 14 deletions src/cli/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,25 +409,31 @@ function runClaudeOutsideTmux(cwd: string, args: string[], _sessionId: string):
const claudeCmd = wrapWithLoginShell(`sleep 0.3; perl -e 'use POSIX;tcflush(0,TCIFLUSH)' 2>/dev/null; ${rawClaudeCmd}`);
const sessionName = buildTmuxSessionName(cwd);

const tmuxArgs = [
'new-session', '-d', '-s', sessionName, '-c', cwd,
claudeCmd,
';', 'set-option', '-t', sessionName, 'mouse', 'on',
];
try {
execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-c', cwd, claudeCmd], { stdio: 'inherit' });
} catch {
runClaudeDirect(cwd, args);
return;
}

// Attach to session
tmuxArgs.push(';', 'attach-session', '-t', sessionName);
try {
execFileSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
} catch {
/* non-fatal — user's tmux may not support these options */
}

try {
execFileSync('tmux', tmuxArgs, { stdio: 'inherit' });
execFileSync('tmux', ['attach-session', '-t', sessionName], { stdio: 'inherit' });
} catch {
// tmux attach failed — kill the orphaned detached session that
// new-session -d just created so they don't accumulate.
// If the detached session still exists, preserve it so interrupted
// attach paths (SSH disconnect, terminal drop, etc.) do not kill or
// duplicate a valid Claude session.
try {
execFileSync('tmux', ['kill-session', '-t', sessionName], { stdio: 'ignore' });
} catch { /* session may already be gone */ }
// fall back to direct launch
runClaudeDirect(cwd, args);
execFileSync('tmux', ['has-session', '-t', sessionName], { stdio: 'ignore' });
return;
} catch {
runClaudeDirect(cwd, args);
}
}
}

Expand Down
Loading