diff --git a/docs/configuration.md b/docs/configuration.md index 4009ef9e..0ea3d032 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ Settings are organized into tabs: | Tab | Contents | |-----|----------| -| **General** | Font family and size, terminal width, log level and buffer, max output lines, shell configuration, input send behavior, default toggles (history, thinking), power management, updates, privacy, context warnings, usage stats, document graph, storage location | +| **General** | Font family and size, terminal width, log level and buffer, max output lines, shell configuration, input send behavior, default toggles (history, thinking), automatic tab naming, power management, updates, privacy, context warnings, usage stats, document graph, storage location | | **Shortcuts** | Customize keyboard shortcuts (see [Keyboard Shortcuts](./keyboard-shortcuts)) | | **Themes** | Dark, light, and vibe mode themes, custom theme builder with import/export | | **Notifications** | OS notifications, custom command notifications, toast notification duration | diff --git a/docs/features.md b/docs/features.md index 3aff7297..ab7f4a55 100644 --- a/docs/features.md +++ b/docs/features.md @@ -28,6 +28,7 @@ icon: sparkles - πŸ” **[Powerful Output Filtering](./general-usage)** - Search and filter AI output with include/exclude modes, regex support, and per-response local filters. - ⚑ **[Slash Commands](./slash-commands)** - Extensible command system with autocomplete. Create custom commands with template variables for your workflows. Includes bundled [Spec-Kit](./speckit-commands) for feature specifications and [OpenSpec](./openspec-commands) for change proposals. - πŸ’Ύ **Draft Auto-Save** - Never lose work. Drafts are automatically saved and restored per session. +- 🏷️ **[Automatic Tab Naming](./general-usage#automatic-tab-naming)** - Tabs are automatically named based on your first message. No more "New Session" clutter β€” each tab gets a descriptive, relevant name. - πŸ”” **Custom Notifications** - Execute any command when agents complete tasks, perfect for audio alerts, logging, or integration with your notification stack. - 🎨 **[Beautiful Themes](https://github.com/pedramamini/Maestro/blob/main/THEMES.md)** - 17 built-in themes across dark (Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark), light (GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light), and vibe (Pedurple, Maestro's Choice, Dre Synth, InQuest) categories, plus a fully customizable theme builder. - πŸ’° **Cost Tracking** - Real-time token usage and cost tracking per session and globally. diff --git a/docs/general-usage.md b/docs/general-usage.md index 44fb2d2c..2e2f8821 100644 --- a/docs/general-usage.md +++ b/docs/general-usage.md @@ -324,6 +324,44 @@ Click the sidebar toggle (`Opt+Cmd+Left` / `Alt+Ctrl+Left`) to collapse the side - Hover for agent name tooltip - Click to select an agent +## Tab Management + +Each agent session can have multiple tabs, allowing you to work on different tasks within the same project workspace. + +### Automatic Tab Naming + +When you send your first message to a new tab, Maestro automatically generates a descriptive name based on your request. This helps you identify tabs at a glance without manual renaming. + +**How it works:** +1. When you start a new conversation in a tab, your first message is analyzed +2. An AI generates a concise, relevant name (2-5 words) +3. The tab name updates automatically once the name is generated +4. If you've already renamed the tab, automatic naming is skipped + +**Examples of generated tab names:** +| Your message | Generated name | +|--------------|----------------| +| "Help me implement user authentication with JWT" | JWT Auth Implementation | +| "Fix the bug in the checkout flow" | Checkout Bug Fix | +| "Add dark mode support to the app" | Dark Mode Support | +| "Refactor the database queries" | Database Query Refactor | + +**Configuring automatic tab naming:** +- Go to **Settings** (`Cmd+,` / `Ctrl+,`) β†’ **General** +- Toggle **Automatic Tab Naming** on or off +- Default: Enabled + + +Automatic tab naming uses the same AI agent as your session, including SSH remote configurations. The naming request runs in parallel with your main prompt, so there's no delay to your workflow. + + +### Manual Tab Renaming + +You can always rename tabs manually: +- Right-click a tab β†’ **Rename Tab** +- Or double-click the tab name to edit it directly +- Manual names take precedence over automatic naming + ## Session Management Browse, star, rename, and resume past sessions. The Session Explorer (`Cmd+Shift+L` / `Ctrl+Shift+L`) shows all conversations for an agent with search, filtering, and quick actions. diff --git a/src/__tests__/main/ipc/handlers/tabNaming.test.ts b/src/__tests__/main/ipc/handlers/tabNaming.test.ts new file mode 100644 index 00000000..e6d9ca3f --- /dev/null +++ b/src/__tests__/main/ipc/handlers/tabNaming.test.ts @@ -0,0 +1,663 @@ +/** + * Tests for Tab Naming IPC Handlers + * + * Tests the IPC handlers for automatic tab naming: + * - tabNaming:generateTabName + */ + +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerTabNamingHandlers } from '../../../../main/ipc/handlers/tabNaming'; +import type { ProcessManager } from '../../../../main/process-manager'; +import type { AgentDetector, AgentConfig } from '../../../../main/agent-detector'; + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, +})); + +// Mock uuid +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mock-uuid-1234'), +})); + +// Mock the prompts +vi.mock('../../../../prompts', () => ({ + tabNamingPrompt: 'You are a tab naming assistant. Generate a concise tab name.', +})); + +// Mock the agent args utilities +vi.mock('../../../../main/utils/agent-args', () => ({ + buildAgentArgs: vi.fn((agent, options) => options.baseArgs || []), + applyAgentConfigOverrides: vi.fn((agent, args, overrides) => ({ + args, + effectiveCustomEnvVars: undefined, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + modelSource: 'default' as const, + })), +})); + +// Mock SSH utilities +vi.mock('../../../../main/utils/ssh-remote-resolver', () => ({ + getSshRemoteConfig: vi.fn(() => ({ config: null, source: 'none' })), + createSshRemoteStoreAdapter: vi.fn(() => ({ + getSshRemotes: vi.fn(() => []), + })), +})); + +vi.mock('../../../../main/utils/ssh-command-builder', () => ({ + buildSshCommand: vi.fn(), +})); + +// Capture registered handlers +const registeredHandlers: Map Promise> = new Map(); + +describe('Tab Naming IPC Handlers', () => { + let mockProcessManager: { + spawn: Mock; + kill: Mock; + on: Mock; + off: Mock; + }; + + let mockAgentDetector: { + getAgent: Mock; + }; + + let mockAgentConfigsStore: { + get: Mock; + set: Mock; + }; + + let mockSettingsStore: { + get: Mock; + set: Mock; + }; + + const mockClaudeAgent: AgentConfig = { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + path: '/usr/local/bin/claude', + args: ['--print'], + batchModeArgs: ['--print'], + readOnlyArgs: ['--read-only'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + registeredHandlers.clear(); + + // Capture handler registrations + (ipcMain.handle as Mock).mockImplementation( + (channel: string, handler: (...args: unknown[]) => Promise) => { + registeredHandlers.set(channel, handler); + } + ); + + // Create mock process manager + mockProcessManager = { + spawn: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + // Create mock agent detector + mockAgentDetector = { + getAgent: vi.fn().mockResolvedValue(mockClaudeAgent), + }; + + // Create mock stores + mockAgentConfigsStore = { + get: vi.fn().mockReturnValue({}), + set: vi.fn(), + }; + + mockSettingsStore = { + get: vi.fn().mockReturnValue({}), + set: vi.fn(), + }; + + // Register handlers + registerTabNamingHandlers({ + getProcessManager: () => mockProcessManager as unknown as ProcessManager, + getAgentDetector: () => mockAgentDetector as unknown as AgentDetector, + agentConfigsStore: mockAgentConfigsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['agentConfigsStore'], + settingsStore: mockSettingsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['settingsStore'], + }); + }); + + // Helper to invoke a registered handler + async function invokeHandler(channel: string, ...args: unknown[]): Promise { + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`No handler registered for channel: ${channel}`); + } + // IPC handlers receive (event, ...args), but our wrapper strips the event + return handler({}, ...args); + } + + describe('handler registration', () => { + it('registers the tabNaming:generateTabName handler', () => { + expect(ipcMain.handle).toHaveBeenCalledWith( + 'tabNaming:generateTabName', + expect.any(Function) + ); + }); + }); + + describe('tabNaming:generateTabName', () => { + it('returns null when agent is not found', async () => { + mockAgentDetector.getAgent.mockResolvedValue(null); + + const result = await invokeHandler('tabNaming:generateTabName', { + userMessage: 'Help me implement a login form', + agentType: 'unknown-agent', + cwd: '/test/project', + }); + + expect(result).toBeNull(); + }); + + it('spawns a process with the correct configuration', async () => { + // Simulate process events + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + // Start the handler but don't await it yet + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Help me implement a login form', + agentType: 'claude-code', + cwd: '/test/project', + }); + + // Wait for spawn to be called + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Verify spawn was called with correct config + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: expect.stringContaining('tab-naming-'), + toolType: 'claude-code', + cwd: '/test/project', + prompt: expect.stringContaining('Help me implement a login form'), + }) + ); + + // Simulate process output and exit + onDataCallback?.('tab-naming-mock-uuid-1234', 'Login Form Implementation'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Login Form Implementation'); + }); + + it('extracts tab name from agent output with ANSI codes', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Fix the authentication bug', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate output with ANSI escape codes + onDataCallback?.('tab-naming-mock-uuid-1234', '\x1B[32mAuth Bug Fix\x1B[0m'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Auth Bug Fix'); + }); + + it('extracts tab name from output with markdown formatting', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Add a dark mode toggle', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate output with markdown formatting + onDataCallback?.('tab-naming-mock-uuid-1234', '**Dark Mode Toggle**'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Dark Mode Toggle'); + }); + + it('returns null for empty output', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Hello', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate empty output + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBeNull(); + }); + + it('returns null on timeout', async () => { + vi.useFakeTimers(); + + mockProcessManager.on.mockImplementation(() => {}); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Help me with something', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Advance time past the timeout (30 seconds) + vi.advanceTimersByTime(31000); + + const result = await resultPromise; + expect(result).toBeNull(); + expect(mockProcessManager.kill).toHaveBeenCalledWith('tab-naming-mock-uuid-1234'); + + vi.useRealTimers(); + }); + + it('cleans up listeners on completion', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test cleanup', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate process completion + onDataCallback?.('tab-naming-mock-uuid-1234', 'Test Tab'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + await resultPromise; + + // Verify listeners were cleaned up + expect(mockProcessManager.off).toHaveBeenCalledWith('data', expect.any(Function)); + expect(mockProcessManager.off).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('ignores events from other sessions', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'My specific request', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Send data from a different session (should be ignored) + onDataCallback?.('other-session-id', 'Wrong Tab Name'); + + // Send data from the correct session + onDataCallback?.('tab-naming-mock-uuid-1234', 'Correct Tab Name'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Correct Tab Name'); + }); + + it('truncates very long tab names', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Something complex', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate a very long tab name (over 50 chars) + const longName = 'This Is A Very Long Tab Name That Should Be Truncated Because It Exceeds The Maximum Length'; + onDataCallback?.('tab-naming-mock-uuid-1234', longName); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).not.toBeNull(); + expect((result as string).length).toBeLessThanOrEqual(50); + expect((result as string).endsWith('...')).toBe(true); + }); + + it('removes quotes from tab names', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Something with quotes', + agentType: 'claude-code', + cwd: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Simulate output with quotes + onDataCallback?.('tab-naming-mock-uuid-1234', '"Quoted Tab Name"'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Quoted Tab Name'); + }); + + it('handles process manager not available', async () => { + // Re-register with null process manager + registeredHandlers.clear(); + (ipcMain.handle as Mock).mockImplementation( + (channel: string, handler: (...args: unknown[]) => Promise) => { + registeredHandlers.set(channel, handler); + } + ); + + registerTabNamingHandlers({ + getProcessManager: () => null, + getAgentDetector: () => mockAgentDetector as unknown as AgentDetector, + agentConfigsStore: mockAgentConfigsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['agentConfigsStore'], + settingsStore: mockSettingsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['settingsStore'], + }); + + await expect( + invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test', + agentType: 'claude-code', + cwd: '/test', + }) + ).rejects.toThrow('Process manager'); + }); + }); +}); + +describe('extractTabName utility', () => { + // Test the extractTabName function indirectly through the handler + // Since it's not exported, we test its behavior through the IPC handler + + describe('edge cases', () => { + let mockProcessManager: { + spawn: Mock; + kill: Mock; + on: Mock; + off: Mock; + }; + + let mockAgentDetector: { + getAgent: Mock; + }; + + let mockAgentConfigsStore: { + get: Mock; + set: Mock; + }; + + let mockSettingsStore: { + get: Mock; + set: Mock; + }; + + const mockAgent: AgentConfig = { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + path: '/usr/local/bin/claude', + args: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + registeredHandlers.clear(); + + (ipcMain.handle as Mock).mockImplementation( + (channel: string, handler: (...args: unknown[]) => Promise) => { + registeredHandlers.set(channel, handler); + } + ); + + mockProcessManager = { + spawn: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + mockAgentDetector = { + getAgent: vi.fn().mockResolvedValue(mockAgent), + }; + + mockAgentConfigsStore = { + get: vi.fn().mockReturnValue({}), + set: vi.fn(), + }; + + mockSettingsStore = { + get: vi.fn().mockReturnValue({}), + set: vi.fn(), + }; + + registerTabNamingHandlers({ + getProcessManager: () => mockProcessManager as unknown as ProcessManager, + getAgentDetector: () => mockAgentDetector as unknown as AgentDetector, + agentConfigsStore: mockAgentConfigsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['agentConfigsStore'], + settingsStore: mockSettingsStore as unknown as Parameters< + typeof registerTabNamingHandlers + >[0]['settingsStore'], + }); + }); + + async function invokeHandler(channel: string, ...args: unknown[]): Promise { + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`No handler registered for channel: ${channel}`); + } + return handler({}, ...args); + } + + it('returns null for whitespace-only output', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test', + agentType: 'claude-code', + cwd: '/test', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + onDataCallback?.('tab-naming-mock-uuid-1234', ' \n\t \n '); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBeNull(); + }); + + it('returns null for single character output', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test', + agentType: 'claude-code', + cwd: '/test', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + onDataCallback?.('tab-naming-mock-uuid-1234', 'A'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBeNull(); + }); + + it('handles multiple lines and uses the last meaningful one', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test', + agentType: 'claude-code', + cwd: '/test', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + // Agent might output explanatory text before the actual name + onDataCallback?.('tab-naming-mock-uuid-1234', 'Here is a suggested tab name.\nActual Tab Name'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Actual Tab Name'); + }); + + it('removes backticks from code-formatted output', async () => { + let onDataCallback: ((sessionId: string, data: string) => void) | undefined; + let onExitCallback: ((sessionId: string) => void) | undefined; + + mockProcessManager.on.mockImplementation((event: string, callback: (...args: any[]) => void) => { + if (event === 'data') onDataCallback = callback; + if (event === 'exit') onExitCallback = callback; + }); + + const resultPromise = invokeHandler('tabNaming:generateTabName', { + userMessage: 'Test', + agentType: 'claude-code', + cwd: '/test', + }); + + await vi.waitFor(() => { + expect(mockProcessManager.spawn).toHaveBeenCalled(); + }); + + onDataCallback?.('tab-naming-mock-uuid-1234', '`Code Style Name`'); + onExitCallback?.('tab-naming-mock-uuid-1234'); + + const result = await resultPromise; + expect(result).toBe('Code Style Name'); + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/useSettings.test.ts b/src/__tests__/renderer/hooks/useSettings.test.ts index 87217f29..5f229837 100644 --- a/src/__tests__/renderer/hooks/useSettings.test.ts +++ b/src/__tests__/renderer/hooks/useSettings.test.ts @@ -138,6 +138,13 @@ describe('useSettings', () => { expect(result.current.disableConfetti).toBe(false); }); + it('should have correct default values for tab naming settings', async () => { + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + expect(result.current.automaticTabNamingEnabled).toBe(true); + }); + it('should have default shortcuts', async () => { const { result } = renderHook(() => useSettings()); await waitForSettingsLoaded(result); @@ -392,6 +399,17 @@ describe('useSettings', () => { expect(result.current.disableGpuAcceleration).toBe(true); expect(result.current.disableConfetti).toBe(true); }); + + it('should load tab naming settings from saved values', async () => { + vi.mocked(window.maestro.settings.getAll).mockResolvedValue({ + automaticTabNamingEnabled: false, + }); + + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + expect(result.current.automaticTabNamingEnabled).toBe(false); + }); }); describe('setter functions - LLM settings', () => { @@ -775,6 +793,40 @@ describe('useSettings', () => { }); }); + describe('setter functions - tab naming settings', () => { + it('should update automaticTabNamingEnabled and persist to settings', async () => { + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + // Default is true, so toggle to false + act(() => { + result.current.setAutomaticTabNamingEnabled(false); + }); + + expect(result.current.automaticTabNamingEnabled).toBe(false); + expect(window.maestro.settings.set).toHaveBeenCalledWith('automaticTabNamingEnabled', false); + }); + + it('should toggle automaticTabNamingEnabled back to true', async () => { + // Start with false + vi.mocked(window.maestro.settings.getAll).mockResolvedValue({ + automaticTabNamingEnabled: false, + }); + + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + expect(result.current.automaticTabNamingEnabled).toBe(false); + + act(() => { + result.current.setAutomaticTabNamingEnabled(true); + }); + + expect(result.current.automaticTabNamingEnabled).toBe(true); + expect(window.maestro.settings.set).toHaveBeenCalledWith('automaticTabNamingEnabled', true); + }); + }); + describe('global stats', () => { it('should update globalStats with setGlobalStats', async () => { const { result } = renderHook(() => useSettings()); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 9df06eec..f7e1b18e 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -50,6 +50,7 @@ import { registerLeaderboardHandlers, LeaderboardHandlerDependencies } from './l import { registerNotificationsHandlers } from './notifications'; import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphony'; import { registerAgentErrorHandlers } from './agent-error'; +import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { AgentDetector } from '../../agent-detector'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -89,6 +90,8 @@ export type { LeaderboardHandlerDependencies }; export { registerNotificationsHandlers }; export { registerSymphonyHandlers }; export { registerAgentErrorHandlers }; +export { registerTabNamingHandlers }; +export type { TabNamingHandlerDependencies }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -259,6 +262,13 @@ export function registerAllHandlers(deps: HandlerDependencies): void { }); // Register agent error handlers (error state management) registerAgentErrorHandlers(); + // Register tab naming handlers for automatic tab naming + registerTabNamingHandlers({ + getProcessManager: deps.getProcessManager, + getAgentDetector: deps.getAgentDetector, + agentConfigsStore: deps.agentConfigsStore, + settingsStore: deps.settingsStore, + }); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts new file mode 100644 index 00000000..8501046f --- /dev/null +++ b/src/main/ipc/handlers/tabNaming.ts @@ -0,0 +1,280 @@ +/** + * Tab Naming IPC Handlers + * + * This module provides IPC handlers for automatic tab naming, + * spawning an ephemeral agent session to generate a descriptive tab name + * based on the user's first message. + * + * Usage: + * - window.maestro.tabNaming.generateTabName(userMessage, agentType, cwd, sshRemoteConfig?) + */ + +import { ipcMain } from 'electron'; +import Store from 'electron-store'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../../utils/logger'; +import { withIpcErrorLogging, requireDependency, CreateHandlerOptions } from '../../utils/ipcHandler'; +import { buildAgentArgs, applyAgentConfigOverrides } from '../../utils/agent-args'; +import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; +import { buildSshCommand } from '../../utils/ssh-command-builder'; +import { tabNamingPrompt } from '../../../prompts'; +import type { ProcessManager } from '../../process-manager'; +import type { AgentDetector } from '../../agent-detector'; +import type { MaestroSettings } from './persistence'; + +const LOG_CONTEXT = '[TabNaming]'; + +/** + * Helper to create handler options with consistent context + */ +const handlerOpts = ( + operation: string, + extra?: Partial +): Pick => ({ + context: LOG_CONTEXT, + operation, + logSuccess: false, + ...extra, +}); + +/** + * Interface for agent configuration store data + */ +interface AgentConfigsData { + configs: Record>; +} + +/** + * Dependencies required for tab naming handler registration + */ +export interface TabNamingHandlerDependencies { + getProcessManager: () => ProcessManager | null; + getAgentDetector: () => AgentDetector | null; + agentConfigsStore: Store; + settingsStore: Store; +} + +/** + * Timeout for tab naming requests (30 seconds) + * This is a short timeout since we want quick response + */ +const TAB_NAMING_TIMEOUT_MS = 30 * 1000; + +/** + * Register Tab Naming IPC handlers. + * + * These handlers support automatic tab naming: + * - generateTabName: Generate a tab name from user's first message + */ +export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): void { + const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore } = deps; + + logger.info('Registering tab naming IPC handlers', LOG_CONTEXT); + + // Generate a tab name from user's first message + ipcMain.handle( + 'tabNaming:generateTabName', + withIpcErrorLogging( + handlerOpts('generateTabName'), + async (config: { + userMessage: string; + agentType: string; + cwd: string; + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; + }): Promise => { + const processManager = requireDependency(getProcessManager, 'Process manager'); + const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); + + // Generate a unique session ID for this ephemeral request + const sessionId = `tab-naming-${uuidv4()}`; + + logger.debug('Starting tab naming request', LOG_CONTEXT, { + sessionId, + agentType: config.agentType, + messageLength: config.userMessage.length, + }); + + try { + // Get the agent configuration + const agent = await agentDetector.getAgent(config.agentType); + if (!agent) { + logger.warn('Agent not found for tab naming', LOG_CONTEXT, { + agentType: config.agentType, + }); + return null; + } + + // Build the prompt: combine the tab naming prompt with the user's message + const fullPrompt = `${tabNamingPrompt}\n\n---\n\nUser's message:\n\n${config.userMessage}`; + + // Build agent arguments - minimal configuration, read-only mode + let finalArgs = buildAgentArgs(agent, { + baseArgs: agent.args ?? [], + prompt: fullPrompt, + cwd: config.cwd, + readOnlyMode: true, // Always read-only since we're not modifying anything + }); + + // Apply config overrides from store + const allConfigs = agentConfigsStore.get('configs', {}); + const agentConfigValues = allConfigs[config.agentType] || {}; + const configResolution = applyAgentConfigOverrides(agent, finalArgs, { + agentConfigValues, + }); + finalArgs = configResolution.args; + + // Determine command and working directory + let command = agent.path || agent.command; + let cwd = config.cwd; + const customEnvVars: Record | undefined = + configResolution.effectiveCustomEnvVars; + + // Handle SSH remote execution if configured + if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig.remoteId) { + const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); + const sshResult = getSshRemoteConfig(sshStoreAdapter, { + sessionSshConfig: config.sessionSshRemoteConfig, + }); + + if (sshResult.config) { + // Use the agent's command (not path) for remote execution + // since the path is local and remote host has its own binary location + const remoteCommand = agent.command; + const remoteCwd = + config.sessionSshRemoteConfig.workingDirOverride || config.cwd; + + const sshCommand = await buildSshCommand(sshResult.config, { + command: remoteCommand, + args: finalArgs, + cwd: remoteCwd, + env: customEnvVars, + }); + command = sshCommand.command; + finalArgs = sshCommand.args; + // Local cwd is not used for SSH commands - the command runs on remote + cwd = process.cwd(); + } + } + + // Create a promise that resolves when we get the tab name + return new Promise((resolve) => { + let output = ''; + let resolved = false; + + // Set timeout + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + logger.warn('Tab naming request timed out', LOG_CONTEXT, { sessionId }); + processManager.kill(sessionId); + resolve(null); + } + }, TAB_NAMING_TIMEOUT_MS); + + // Listen for data from the process + const onData = (dataSessionId: string, data: string) => { + if (dataSessionId !== sessionId) return; + output += data; + }; + + // Listen for process exit + const onExit = (exitSessionId: string) => { + if (exitSessionId !== sessionId) return; + + // Clean up + clearTimeout(timeoutId); + processManager.off('data', onData); + processManager.off('exit', onExit); + + if (resolved) return; + resolved = true; + + // Extract the tab name from the output + // The agent should return just the tab name, but we clean up any extra whitespace/formatting + const tabName = extractTabName(output); + logger.debug('Tab naming completed', LOG_CONTEXT, { + sessionId, + outputLength: output.length, + tabName, + }); + resolve(tabName); + }; + + processManager.on('data', onData); + processManager.on('exit', onExit); + + // Spawn the process + processManager.spawn({ + sessionId, + toolType: config.agentType, + cwd, + command, + args: finalArgs, + prompt: fullPrompt, + customEnvVars, + }); + }); + } catch (error) { + logger.error('Tab naming request failed', LOG_CONTEXT, { + sessionId, + error: String(error), + }); + // Clean up the process if it was started + try { + processManager.kill(sessionId); + } catch { + // Ignore cleanup errors + } + return null; + } + } + ) + ); +} + +/** + * Extract a clean tab name from agent output. + * The output may contain ANSI codes, extra whitespace, or markdown formatting. + */ +function extractTabName(output: string): string | null { + if (!output || !output.trim()) { + return null; + } + + // Remove ANSI escape codes + let cleaned = output.replace(/\x1B\[[0-9;]*[mGKH]/g, ''); + + // Remove any markdown formatting (bold, italic, code blocks) + cleaned = cleaned.replace(/\*\*/g, '').replace(/\*/g, '').replace(/`/g, ''); + + // Remove any newlines and extra whitespace + cleaned = cleaned.replace(/[\n\r]+/g, ' ').trim(); + + // Take only the last line if there are multiple (agent may have preamble) + const lines = cleaned.split(/[.\n]/).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return null; + } + + // Use the last meaningful line (often the actual tab name) + let tabName = lines[lines.length - 1].trim(); + + // Remove any leading/trailing quotes + tabName = tabName.replace(/^["']|["']$/g, ''); + + // Ensure reasonable length (max 50 chars for tab names) + if (tabName.length > 50) { + tabName = tabName.substring(0, 47) + '...'; + } + + // If the result is empty or too short, return null + if (tabName.length < 2) { + return null; + } + + return tabName; +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index ddabd122..ec7b3521 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -47,6 +47,7 @@ import { createGitApi } from './git'; import { createFsApi } from './fs'; import { createAgentsApi } from './agents'; import { createSymphonyApi } from './symphony'; +import { createTabNamingApi } from './tabNaming'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -176,6 +177,9 @@ contextBridge.exposeInMainWorld('maestro', { // Symphony API (token donations / open source contributions) symphony: createSymphonyApi(), + + // Tab Naming API (automatic tab name generation) + tabNaming: createTabNamingApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -243,6 +247,8 @@ export { createAgentsApi, // Symphony createSymphonyApi, + // Tab Naming + createTabNamingApi, }; // Re-export types for TypeScript consumers @@ -433,3 +439,8 @@ export type { CreateDraftPRResponse, CompleteContributionResponse, } from './symphony'; +export type { + // From tabNaming + TabNamingApi, + TabNamingConfig, +} from './tabNaming'; diff --git a/src/main/preload/tabNaming.ts b/src/main/preload/tabNaming.ts new file mode 100644 index 00000000..8a6dd13d --- /dev/null +++ b/src/main/preload/tabNaming.ts @@ -0,0 +1,51 @@ +/** + * Preload API for automatic tab naming + * + * Provides the window.maestro.tabNaming namespace for: + * - Generating descriptive tab names from user's first message + */ + +import { ipcRenderer } from 'electron'; + +/** + * Configuration for tab name generation request + */ +export interface TabNamingConfig { + /** The user's first message to analyze */ + userMessage: string; + /** The agent type to use (e.g., 'claude-code') */ + agentType: string; + /** Working directory for the session */ + cwd: string; + /** Optional SSH remote configuration */ + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; +} + +/** + * Tab Naming API exposed to the renderer process + */ +export interface TabNamingApi { + /** + * Generate a descriptive tab name from the user's first message. + * This spawns an ephemeral agent session that analyzes the message + * and returns a short, relevant tab name. + * + * @param config - Configuration for the tab naming request + * @returns The generated tab name, or null if generation failed + */ + generateTabName: (config: TabNamingConfig) => Promise; +} + +/** + * Create the tab naming API for exposure via contextBridge + */ +export function createTabNamingApi(): TabNamingApi { + return { + generateTabName: (config: TabNamingConfig) => + ipcRenderer.invoke('tabNaming:generateTabName', config), + }; +} diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 852c4052..99d1859e 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -42,4 +42,7 @@ export { contextGroomingPrompt, contextTransferPrompt, contextSummarizePrompt, + + // Tab naming + tabNamingPrompt, } from '../generated/prompts'; diff --git a/src/prompts/tab-naming.md b/src/prompts/tab-naming.md new file mode 100644 index 00000000..6bd944d1 --- /dev/null +++ b/src/prompts/tab-naming.md @@ -0,0 +1,55 @@ +You are a tab naming assistant. Your task is to generate a concise, relevant tab name based on the user's first message to an AI coding assistant. + +## Input + +You will receive a user's first message to an AI coding assistant. This message describes a task, question, or request they want help with. + +## Output + +Respond with ONLY the tab name. No explanation, no quotes, no formattingβ€”just the name itself. + +## Rules + +1. **Length**: 2-5 words maximum. Shorter is better. +2. **Relevance**: Capture the specific intent or subject matter, not generic descriptions. +3. **Uniqueness**: Avoid generic names like "Help Request", "Code Question", "New Task". +4. **Format**: Use Title Case. No special characters except hyphens for compound concepts. +5. **Specificity**: Reference specific technologies, files, or concepts mentioned. + +## Examples + +Message: "Can you help me add a dark mode toggle to my React app?" +β†’ Dark Mode Toggle + +Message: "There's a bug in the user authentication flow where login fails after password reset" +β†’ Auth Login Bug + +Message: "I need to refactor the database queries to use connection pooling" +β†’ DB Connection Pooling + +Message: "Help me write unit tests for the checkout component" +β†’ Checkout Unit Tests + +Message: "What's the best way to implement caching in a Node.js API?" +β†’ Node.js API Caching + +Message: "Fix the TypeScript errors in src/utils/parser.ts" +β†’ Parser TS Errors + +Message: "Add pagination to the user list endpoint" +β†’ User List Pagination + +Message: "I'm getting a CORS error when calling my API from the frontend" +β†’ CORS API Fix + +## Fallback + +If the message is too vague or generic to create a meaningful name, prefix with today's date: + +Message: "Help me with my code" +β†’ YYYY-MM-DD Code Help + +Message: "I have a question" +β†’ YYYY-MM-DD Question + +Replace YYYY-MM-DD with the actual current date. diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 066e0c0b..25fd2d73 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -30,6 +30,7 @@ import { Battery, Monitor, PartyPopper, + Tag, } from 'lucide-react'; import { useSettings } from '../hooks'; import type { @@ -307,6 +308,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro setSshRemoteIgnorePatterns, sshRemoteHonorGitignore, setSshRemoteHonorGitignore, + // Automatic tab naming settings + automaticTabNamingEnabled, + setAutomaticTabNamingEnabled, } = useSettings(); const [activeTab, setActiveTab] = useState< @@ -1404,6 +1408,17 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro theme={theme} /> + {/* Automatic Tab Naming */} + + {/* Default Thinking Toggle - Three states: Off, On, Sticky */}