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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { registerForgejoIpc } from './forgejoIpc';
import { registerAccountIpc } from './accountIpc';
import { changelogController } from './changelogIpc';
import { registerPerformanceIpc } from './performanceIpc';
import { registerTaskNamingIpc } from './taskNamingIpc';

export const rpcRouter = createRPCRouter({
db: databaseController,
Expand Down Expand Up @@ -76,4 +77,5 @@ export function registerAllIpc() {
registerPlainIpc();
registerForgejoIpc();
registerPerformanceIpc();
registerTaskNamingIpc();
}
39 changes: 39 additions & 0 deletions src/main/ipc/taskNamingIpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ipcMain, BrowserWindow } from 'electron';
import { inferTaskNameFromProvider } from '../services/TaskNamingService';
import { log } from '../lib/logger';

export function registerTaskNamingIpc(): void {
/**
* Fire-and-forget task name inference via provider CLI.
* Returns immediately; pushes 'task:nameInferred' to the renderer when done.
*/
ipcMain.handle(
'task:inferName',
async (
event,
args: {
taskId: string;
providerId: string;
initialPrompt: string;
projectPath: string;
}
) => {
const { taskId, providerId, initialPrompt, projectPath } = args;

void inferTaskNameFromProvider(providerId, initialPrompt, projectPath)
.then((name) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed()) return;
win.webContents.send('task:nameInferred', { taskId, name });
})
.catch((err: unknown) => {
log.warn(`[TaskNaming] unexpected error for task ${taskId}: ${String(err)}`);
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed()) return;
win.webContents.send('task:nameInferred', { taskId, name: null });
});

return { accepted: true };
}
);
}
13 changes: 13 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on(channel, wrapped);
return () => ipcRenderer.removeListener(channel, wrapped);
},
taskInferName: (args: {
taskId: string;
providerId: string;
initialPrompt: string;
projectPath: string;
}) => ipcRenderer.invoke('task:inferName', args),
onTaskNameInferred: (listener: (data: { taskId: string; name: string | null }) => void) => {
const channel = 'task:nameInferred';
const wrapped = (_: Electron.IpcRendererEvent, data: { taskId: string; name: string | null }) =>
listener(data);
ipcRenderer.on(channel, wrapped);
return () => ipcRenderer.removeListener(channel, wrapped);
},
terminalGetTheme: () => ipcRenderer.invoke('terminal:getTheme'),

// Menu events (main → renderer)
Expand Down
91 changes: 91 additions & 0 deletions src/main/services/TaskNamingService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { spawn } from 'child_process';
import { resolveProviderCommandConfig } from './ptyManager';
import { log } from '../lib/logger';

const NAMING_PROMPT =
'Output only a short git branch name slug for the following task description. ' +
'Rules: lowercase letters, numbers, and hyphens only; no spaces; max 40 characters; ' +
'no leading or trailing hyphens; be concise and descriptive. ' +
'Output the slug and nothing else.\n\nTask: ';

const TIMEOUT_MS = 15_000;
const MAX_STDOUT_BYTES = 4096;

function normalizeSlug(raw: string): string | null {
const slug = raw
.trim()
.toLowerCase()
.split('\n')[0] // take first line only
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);

return slug.length >= 3 ? slug : null;
}

export async function inferTaskNameFromProvider(
providerId: string,
initialPrompt: string,
cwd: string
): Promise<string | null> {
const resolved = resolveProviderCommandConfig(providerId);
if (!resolved) return null;

const { provider, cli } = resolved;
if (!provider.utilityCliArgs) return null;

const args = [...provider.utilityCliArgs, `${NAMING_PROMPT}${initialPrompt}`];

return new Promise((resolve) => {
let stdout = '';
let settled = false;

const timer = setTimeout(() => {
if (!settled) {
settled = true;
log.warn(`[TaskNaming] timed out for provider: ${providerId}`);
child.kill();
resolve(null);
}
}, TIMEOUT_MS);

const child = spawn(cli, args, {
cwd,
env: process.env as Record<string, string>,
stdio: ['ignore', 'pipe', 'pipe'],
});

child.stdout.on('data', (chunk: Buffer) => {
if (stdout.length < MAX_STDOUT_BYTES) {
stdout += chunk.toString();
}
});

child.stderr?.on('data', (chunk: Buffer) => {
log.debug(`[TaskNaming] stderr from ${providerId}: ${chunk.toString().trim()}`);
});

child.on('close', (code) => {
clearTimeout(timer);
if (settled) return;
settled = true;

if (code !== 0) {
log.warn(`[TaskNaming] CLI exited with code ${code} for provider: ${providerId}`);
resolve(null);
return;
}

resolve(normalizeSlug(stdout));
});

child.on('error', (err) => {
clearTimeout(timer);
if (settled) return;
settled = true;
log.warn(`[TaskNaming] spawn error for ${providerId}: ${err.message}`);
resolve(null);
});
});
}
65 changes: 59 additions & 6 deletions src/renderer/components/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { Task } from '../types/chat';
import { useTaskTerminals } from '@/lib/taskTerminalsStore';
import { activityStore } from '@/lib/activityStore';
import { rpc } from '@/lib/rpc';
import { getInstallCommandForProvider } from '@shared/providers/registry';
import {
getInstallCommandForProvider,
getProvider,
isValidProviderId,
} from '@shared/providers/registry';
import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch';
import { useTerminalViewportWheelForwarding } from '@/hooks/useTerminalViewportWheelForwarding';
import { TaskScopeProvider } from './TaskScopeContext';
Expand Down Expand Up @@ -1099,12 +1103,31 @@ const ChatInterface: React.FC<Props> = ({
// Skip multi-agent tasks
if (task.metadata?.multiAgent?.enabled) return;

const generated = generateTaskName(message);
if (!generated) return;

const existingNames = (project.tasks || []).map((t) => t.name);
const uniqueName = ensureUniqueTaskName(generated, existingNames);
void onRenameTask(project, task, uniqueName);

const applySlugName = () => {
const generated = generateTaskName(message);
if (!generated) return;
void onRenameTask(project, task, ensureUniqueTaskName(generated, existingNames));
};

// Try utility model naming first; result arrives via onTaskNameInferred.
// Fall back to slug generator if the provider doesn't support utility mode or errors.
const providerId = task.agentId;
if (providerId && isValidProviderId(providerId) && getProvider(providerId)?.utilityCliArgs) {
firstMessageRef.current = message;
void window.electronAPI
.taskInferName({
taskId: task.id,
providerId,
initialPrompt: message,
projectPath: project.path,
})
.catch(applySlugName);
return;
}

applySlugName();
},
[project, task, onRenameTask, autoInferTaskNames]
);
Expand All @@ -1118,6 +1141,36 @@ const ChatInterface: React.FC<Props> = ({
onRenameTask
);

// Apply utility-model-inferred name when the main process completes background naming.
// Deps use stable IDs rather than object refs to avoid re-registering on every render.
const projectRef = useRef(project);
projectRef.current = project;
const taskRef = useRef(task);
taskRef.current = task;
const onRenameTaskRef = useRef(onRenameTask);
onRenameTaskRef.current = onRenameTask;
const firstMessageRef = useRef<string | null>(null);
useEffect(() => {
if (!shouldCaptureFirstMessage) return;
const off = window.electronAPI.onTaskNameInferred(({ taskId, name }) => {
const t = taskRef.current;
const p = projectRef.current;
const rename = onRenameTaskRef.current;
if (taskId !== t.id) return;
if (!t.metadata?.nameGenerated) return;
if (!p || !rename) return;

// Fall back to slug generator if inference returned null
const resolvedName =
name ?? (firstMessageRef.current ? generateTaskName(firstMessageRef.current) : null);
if (!resolvedName) return;

const existingNames = (p.tasks || []).map((u) => u.name);
void rename(p, t, ensureUniqueTaskName(resolvedName, existingNames));
});
return off;
}, [task.id, shouldCaptureFirstMessage]);

const markActiveReviewPromptSent = useCallback(() => {
if (!activeConversation || activeConversation.isMain || !activeReviewMetadata) return;
if (activeReviewMetadata.initialPromptSent) return;
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ declare global {
listener: (event: AgentEvent, meta: { appFocused: boolean }) => void
) => () => void;
onNotificationFocusTask: (listener: (taskId: string) => void) => () => void;
taskInferName: (args: {
taskId: string;
providerId: string;
initialPrompt: string;
projectPath: string;
}) => Promise<{ accepted: boolean }>;
onTaskNameInferred: (
listener: (data: { taskId: string; name: string | null }) => void
) => () => void;
terminalGetTheme: () => Promise<{
ok: boolean;
config?: {
Expand Down Expand Up @@ -1449,6 +1458,15 @@ export interface ElectronAPI {
listener: (event: AgentEvent, meta: { appFocused: boolean }) => void
) => () => void;
onNotificationFocusTask: (listener: (taskId: string) => void) => () => void;
taskInferName: (args: {
taskId: string;
providerId: string;
initialPrompt: string;
projectPath: string;
}) => Promise<{ accepted: boolean }>;
onTaskNameInferred: (
listener: (data: { taskId: string; name: string | null }) => void
) => () => void;

// Worktree management
worktreeCreate: (args: {
Expand Down
9 changes: 9 additions & 0 deletions src/shared/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export type ProviderDefinition = {
autoStartCommand?: string;
icon?: string;
terminalOnly?: boolean;
/**
* CLI args to invoke this provider in non-interactive mode for background
* utility tasks (e.g. task naming). When present, the provider will be
* spawned with these args for lightweight work instead of a full session.
* e.g. ['-p', '--model', 'haiku', '--tools', '']
*/
utilityCliArgs?: string[];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces a utilityCliArgs field on ProviderDefinition, a general-purpose mechanism for invoking a provider's CLI in lightweight non-interactive mode

Task naming is the first use case, but the same infrastructure could power other things that could use inference (like generating pull request description drafting, commit messages)

};

export const PROVIDERS: ProviderDefinition[] = [
Expand Down Expand Up @@ -89,6 +96,7 @@ export const PROVIDERS: ProviderDefinition[] = [
planActivateCommand: '/plan',
icon: 'claude.png',
terminalOnly: true,
utilityCliArgs: ['-p', '--model', 'haiku', '--tools', '', '--no-session-persistence'],
},
{
id: 'cursor',
Expand Down Expand Up @@ -116,6 +124,7 @@ export const PROVIDERS: ProviderDefinition[] = [
resumeFlag: '--resume',
icon: 'gemini.png',
terminalOnly: true,
utilityCliArgs: ['--model', 'gemini-2.5-flash', '--prompt'],
},
{
id: 'qwen',
Expand Down
Loading
Loading