feat: add fish shell expansion support (#228) (#242)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: raghav-latte <131696988+raghav-latte@users.noreply.github.com>
This commit is contained in:
Raghav Sethi 2025-07-08 05:14:31 +05:30 committed by GitHub
parent 48ea8898fa
commit 966c671755
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 319 additions and 7 deletions

View file

@ -112,15 +112,28 @@ async function build() {
// Build native executable
console.log('Building native executable...');
// Check for --custom-node flag
const useCustomNode = process.argv.includes('--custom-node');
// Check if native binaries already exist (skip build for development)
const nativeDir = path.join(__dirname, '..', 'native');
const vibetunnelPath = path.join(nativeDir, 'vibetunnel');
const ptyNodePath = path.join(nativeDir, 'pty.node');
const spawnHelperPath = path.join(nativeDir, 'spawn-helper');
if (useCustomNode) {
console.log('Using custom Node.js for smaller binary size...');
execSync('node build-native.js --custom-node', { stdio: 'inherit' });
if (fs.existsSync(vibetunnelPath) && fs.existsSync(ptyNodePath) && fs.existsSync(spawnHelperPath)) {
console.log('✅ Native binaries already exist, skipping build...');
console.log(' - vibetunnel executable: ✓');
console.log(' - pty.node: ✓');
console.log(' - spawn-helper: ✓');
} else {
console.log('Using system Node.js...');
execSync('node build-native.js', { stdio: 'inherit' });
// Check for --custom-node flag
const useCustomNode = process.argv.includes('--custom-node');
if (useCustomNode) {
console.log('Using custom Node.js for smaller binary size...');
execSync('node build-native.js --custom-node', { stdio: 'inherit' });
} else {
console.log('Using system Node.js...');
execSync('node build-native.js', { stdio: 'inherit' });
}
}
console.log('Build completed successfully!');

View file

@ -0,0 +1,106 @@
/**
* Fish Shell Handler
*
* Provides fish shell tab completion support.
*/
import { spawn } from 'child_process';
import path from 'path';
export class FishHandler {
/**
* Get completion suggestions for a partial command
*/
async getCompletions(partial: string, cwd: string = process.cwd()): Promise<string[]> {
return new Promise((resolve) => {
try {
// Use fish's built-in completion system with proper escaping
const fishProcess = spawn('fish', ['-c', `complete -C ${JSON.stringify(partial)}`], {
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
});
let stdout = '';
const timeout = setTimeout(() => {
fishProcess.kill('SIGTERM');
resolve([]);
}, 2000);
fishProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
fishProcess.on('close', (code) => {
clearTimeout(timeout);
if (code !== 0 || !stdout.trim()) {
resolve([]);
return;
}
const completions = stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => line.split('\t')[0]) // Fish completions are tab-separated
.filter((completion) => completion && completion !== partial);
resolve(completions);
});
fishProcess.on('error', () => {
clearTimeout(timeout);
resolve([]);
});
} catch (_error) {
resolve([]);
}
});
}
/**
* Check if the current shell is fish
*/
static isFishShell(shellPath: string): boolean {
const basename = path.basename(shellPath);
// Exact match for fish or fish with version suffix (e.g., fish3)
return basename === 'fish' || /^fish\d*$/.test(basename);
}
/**
* Get fish shell version
*/
static async getFishVersion(): Promise<string | null> {
return new Promise((resolve) => {
try {
const fishProcess = spawn('fish', ['--version'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
let stdout = '';
const timeout = setTimeout(() => {
fishProcess.kill('SIGTERM');
resolve(null);
}, 1000);
fishProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
fishProcess.on('close', (code) => {
clearTimeout(timeout);
resolve(code === 0 && stdout.trim() ? stdout.trim() : null);
});
fishProcess.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
} catch {
resolve(null);
}
});
}
}
// Export singleton instance
export const fishHandler = new FishHandler();

View file

@ -34,6 +34,7 @@ import {
import { WriteQueue } from '../utils/write-queue.js';
import { VERSION } from '../version.js';
import { AsciinemaWriter } from './asciinema-writer.js';
import { FishHandler } from './fish-handler.js';
import { ProcessUtils } from './process-utils.js';
import { SessionManager } from './session-manager.js';
import {
@ -220,6 +221,9 @@ export class PtyManager extends EventEmitter {
const { command: finalCommand, args: finalArgs } = resolved;
const resolvedCommand = [finalCommand, ...finalArgs];
// Note: Fish shell expansion was removed for simplicity
// Only basic tab completion is supported via getFishCompletions()
// Log resolution details
if (resolved.resolvedFrom === 'alias') {
logger.log(
@ -884,6 +888,30 @@ export class PtyManager extends EventEmitter {
}
}
/**
* Get fish shell completions for a partial command
*/
async getFishCompletions(sessionId: string, partial: string): Promise<string[]> {
try {
const session = this.sessions.get(sessionId);
if (!session) {
return [];
}
const userShell = ProcessUtils.getUserShell();
if (!FishHandler.isFishShell(userShell)) {
return [];
}
const { fishHandler } = await import('./fish-handler.js');
const cwd = session.currentWorkingDir || process.cwd();
return await fishHandler.getCompletions(partial, cwd);
} catch (error) {
logger.warn(`Fish completions failed: ${error}`);
return [];
}
}
/**
* Send text input to a session
*/

View file

@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FishHandler } from '../../../server/pty/fish-handler.js';
// Mock child_process
vi.mock('child_process', () => ({
spawn: vi.fn(),
}));
import { spawn } from 'child_process';
const mockSpawn = vi.mocked(spawn);
// Helper to create mock process
const createMockProcess = (stdout: string, exitCode: number = 0, shouldError = false) => {
const mockProcess = {
stdout: {
on: vi.fn((event, callback) => {
if (event === 'data' && !shouldError) {
callback(Buffer.from(stdout));
}
}),
},
on: vi.fn((event, callback) => {
if (event === 'close') {
setTimeout(() => callback(exitCode), 0);
} else if (event === 'error' && shouldError) {
setTimeout(() => callback(new Error('Process error')), 0);
}
}),
kill: vi.fn(),
};
return mockProcess;
};
describe('FishHandler', () => {
let fishHandler: FishHandler;
beforeEach(() => {
fishHandler = new FishHandler();
vi.clearAllMocks();
});
describe('getCompletions', () => {
it('should return empty array when fish command fails', async () => {
const mockProcess = createMockProcess('', 1);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('ls');
expect(result).toEqual([]);
});
it('should return empty array when fish has no stdout', async () => {
const mockProcess = createMockProcess('', 0);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('ls');
expect(result).toEqual([]);
});
it('should parse fish completions correctly', async () => {
const mockProcess = createMockProcess(
'ls\t\nls-color\tColorized ls\nls-files\tList files only\n',
0
);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('ls');
expect(result).toEqual(['ls-color', 'ls-files']);
});
it('should filter out the original partial command', async () => {
const mockProcess = createMockProcess(
'git\t\ngit-add\tAdd files\ngit-commit\tCommit changes\n',
0
);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('git');
expect(result).toEqual(['git-add', 'git-commit']);
});
it('should handle empty completions gracefully', async () => {
const mockProcess = createMockProcess('\n\n\n', 0);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('nonexistent');
expect(result).toEqual([]);
});
it('should handle fish command timeout/errors', async () => {
const mockProcess = createMockProcess('', 0, true);
mockSpawn.mockReturnValue(mockProcess);
const result = await fishHandler.getCompletions('ls');
expect(result).toEqual([]);
});
it('should call fish with correct parameters', async () => {
const mockProcess = createMockProcess('test\n', 0);
mockSpawn.mockReturnValue(mockProcess);
await fishHandler.getCompletions('ls /tmp', '/home/user');
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls /tmp"'], {
cwd: '/home/user',
stdio: ['ignore', 'pipe', 'ignore'],
});
});
it('should use current working directory as default', async () => {
const mockProcess = createMockProcess('test\n', 0);
mockSpawn.mockReturnValue(mockProcess);
await fishHandler.getCompletions('ls');
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls"'], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'ignore'],
});
});
});
describe('isFishShell', () => {
it('should return true for fish shell paths', () => {
expect(FishHandler.isFishShell('/usr/bin/fish')).toBe(true);
expect(FishHandler.isFishShell('/opt/homebrew/bin/fish')).toBe(true);
expect(FishHandler.isFishShell('fish')).toBe(true);
expect(FishHandler.isFishShell('/usr/bin/fish3')).toBe(true);
});
it('should return false for non-fish shells', () => {
expect(FishHandler.isFishShell('/bin/bash')).toBe(false);
expect(FishHandler.isFishShell('/bin/zsh')).toBe(false);
expect(FishHandler.isFishShell('/bin/sh')).toBe(false);
expect(FishHandler.isFishShell('/usr/bin/catfish')).toBe(false);
expect(FishHandler.isFishShell('/usr/bin/fisherman')).toBe(false);
});
});
describe('getFishVersion', () => {
it('should return version when fish is available', async () => {
const mockProcess = createMockProcess('fish, version 3.6.1', 0);
mockSpawn.mockReturnValue(mockProcess);
const version = await FishHandler.getFishVersion();
expect(version).toBe('fish, version 3.6.1');
});
it('should return null when fish is not available', async () => {
const mockProcess = createMockProcess('', 1);
mockSpawn.mockReturnValue(mockProcess);
const version = await FishHandler.getFishVersion();
expect(version).toBeNull();
});
it('should return null when fish command throws', async () => {
const mockProcess = createMockProcess('', 0, true);
mockSpawn.mockReturnValue(mockProcess);
const version = await FishHandler.getFishVersion();
expect(version).toBeNull();
});
});
});