feat(tabs): add automatic tab naming based on first message

When a new session starts and the first message is sent, Maestro now
automatically generates a descriptive tab name based on the user's
request. This runs in parallel with the main prompt processing and
uses the same AI agent (honoring SSH remote configurations).

Implementation:
- Add tab naming prompt at src/prompts/tab-naming.md
- Add IPC handler (tabNaming:generateTabName) that spawns ephemeral
  session to generate names with 30s timeout
- Integrate with onSessionId callback to trigger naming for new tabs
- Only update name if tab is still in UUID format (user hasn't renamed)
- Add automaticTabNamingEnabled setting (default: true)
- Add Settings UI checkbox under General section

Tab names are 2-5 words, Title Case, and capture the specific intent
rather than generic descriptions. Examples:
- "Help me implement JWT auth" → "JWT Auth Implementation"
- "Fix the checkout bug" → "Checkout Bug Fix"

Tests: 22 new tests covering IPC handler, settings, and edge cases
Docs: Updated general-usage.md, features.md, and configuration.md
This commit is contained in:
Pedram Amini
2026-02-02 02:40:13 -06:00
parent 2f945ec032
commit dd340da30f
15 changed files with 1222 additions and 3 deletions

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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
<Note>
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.
</Note>
### 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.

View File

@@ -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<string, (...args: unknown[]) => Promise<unknown>> = 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<unknown>) => {
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<unknown> {
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<unknown>) => {
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<unknown>) => {
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<unknown> {
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');
});
});
});

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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<CreateHandlerOptions>
): Pick<CreateHandlerOptions, 'context' | 'operation' | 'logSuccess'> => ({
context: LOG_CONTEXT,
operation,
logSuccess: false,
...extra,
});
/**
* Interface for agent configuration store data
*/
interface AgentConfigsData {
configs: Record<string, Record<string, any>>;
}
/**
* Dependencies required for tab naming handler registration
*/
export interface TabNamingHandlerDependencies {
getProcessManager: () => ProcessManager | null;
getAgentDetector: () => AgentDetector | null;
agentConfigsStore: Store<AgentConfigsData>;
settingsStore: Store<MaestroSettings>;
}
/**
* 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<string | null> => {
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<string, string> | 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<string | null>((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;
}

View File

@@ -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';

View File

@@ -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<string | null>;
}
/**
* Create the tab naming API for exposure via contextBridge
*/
export function createTabNamingApi(): TabNamingApi {
return {
generateTabName: (config: TabNamingConfig) =>
ipcRenderer.invoke('tabNaming:generateTabName', config),
};
}

View File

@@ -42,4 +42,7 @@ export {
contextGroomingPrompt,
contextTransferPrompt,
contextSummarizePrompt,
// Tab naming
tabNamingPrompt,
} from '../generated/prompts';

55
src/prompts/tab-naming.md Normal file
View File

@@ -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.

View File

@@ -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 */}
<SettingCheckbox
icon={Tag}
sectionLabel="Automatic Tab Naming"
title="Automatically name tabs based on first message"
description="When you send your first message to a new tab, an AI will analyze it and generate a descriptive tab name. The naming request runs in parallel and leaves no history."
checked={automaticTabNamingEnabled}
onChange={setAutomaticTabNamingEnabled}
theme={theme}
/>
{/* Default Thinking Toggle - Three states: Off, On, Sticky */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">

View File

@@ -101,6 +101,9 @@ export const MODAL_PRIORITIES = {
/** Batch runner modal for scratchpad auto mode */
BATCH_RUNNER: 720,
/** Document selector modal (opens from BatchRunner to add documents) */
DOCUMENT_SELECTOR: 725,
/** Tab switcher modal (Opt+Cmd+T) */
TAB_SWITCHER: 710,
@@ -122,8 +125,8 @@ export const MODAL_PRIORITIES = {
/** Auto Run search bar (within expanded modal) */
AUTORUN_SEARCH: 706,
/** Playbook Exchange modal - browse and import community playbooks */
MARKETPLACE: 708,
/** Playbook Exchange modal - browse and import community playbooks (opens from BatchRunner or AutoRunExpanded, so needs higher priority than both) */
MARKETPLACE: 735,
/** Symphony modal - browse and contribute to open source projects */
SYMPHONY: 710,

View File

@@ -2588,6 +2588,20 @@ interface MaestroAPI {
callback: (data: { contributionId: string; prNumber: number; prUrl: string }) => void
) => () => void;
};
// Tab Naming API (automatic tab name generation)
tabNaming: {
generateTabName: (config: {
userMessage: string;
agentType: string;
cwd: string;
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}) => Promise<string | null>;
};
}
declare global {

View File

@@ -342,6 +342,10 @@ export interface UseSettingsReturn {
setSshRemoteIgnorePatterns: (value: string[]) => void;
sshRemoteHonorGitignore: boolean;
setSshRemoteHonorGitignore: (value: boolean) => void;
// Automatic tab naming settings
automaticTabNamingEnabled: boolean;
setAutomaticTabNamingEnabled: (value: boolean) => void;
}
export function useSettings(): UseSettingsReturn {
@@ -494,6 +498,9 @@ export function useSettings(): UseSettingsReturn {
]);
const [sshRemoteHonorGitignore, setSshRemoteHonorGitignoreState] = useState(true); // Default: honor .gitignore
// Automatic tab naming settings
const [automaticTabNamingEnabled, setAutomaticTabNamingEnabledState] = useState(true); // Default: enabled
// Wrapper functions that persist to electron-store
// PERF: All wrapped in useCallback to prevent re-renders
const setLlmProvider = useCallback((value: LLMProvider) => {
@@ -1296,6 +1303,12 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('sshRemoteHonorGitignore', value);
}, []);
// Automatic tab naming toggle
const setAutomaticTabNamingEnabled = useCallback((value: boolean) => {
setAutomaticTabNamingEnabledState(value);
window.maestro.settings.set('automaticTabNamingEnabled', value);
}, []);
// Load settings from electron-store
// This function is called on mount and on system resume (after sleep/suspend)
// PERF: Use batch loading to reduce IPC calls from ~60 to 3
@@ -1368,6 +1381,7 @@ export function useSettings(): UseSettingsReturn {
const savedDisableConfetti = allSettings['disableConfetti'];
const savedSshRemoteIgnorePatterns = allSettings['sshRemoteIgnorePatterns'];
const savedSshRemoteHonorGitignore = allSettings['sshRemoteHonorGitignore'];
const savedAutomaticTabNamingEnabled = allSettings['automaticTabNamingEnabled'];
if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI as boolean);
if (savedEnterToSendTerminal !== undefined)
@@ -1722,6 +1736,11 @@ export function useSettings(): UseSettingsReturn {
if (savedSshRemoteHonorGitignore !== undefined) {
setSshRemoteHonorGitignoreState(savedSshRemoteHonorGitignore as boolean);
}
// Automatic tab naming settings
if (savedAutomaticTabNamingEnabled !== undefined) {
setAutomaticTabNamingEnabledState(savedAutomaticTabNamingEnabled as boolean);
}
} catch (error) {
console.error('[Settings] Failed to load settings:', error);
} finally {
@@ -1896,6 +1915,8 @@ export function useSettings(): UseSettingsReturn {
setSshRemoteIgnorePatterns,
sshRemoteHonorGitignore,
setSshRemoteHonorGitignore,
automaticTabNamingEnabled,
setAutomaticTabNamingEnabled,
}),
[
// State values
@@ -2037,6 +2058,8 @@ export function useSettings(): UseSettingsReturn {
setSshRemoteIgnorePatterns,
sshRemoteHonorGitignore,
setSshRemoteHonorGitignore,
automaticTabNamingEnabled,
setAutomaticTabNamingEnabled,
]
);
}