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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions electron/main/engines/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
unstable_v2_resumeSession,
listSessions as sdkListSessions,
getSessionMessages as sdkGetSessionMessages,
renameSession as sdkRenameSession,
query as sdkQuery,
} from "@anthropic-ai/claude-agent-sdk";
import type {
Expand Down Expand Up @@ -577,6 +578,35 @@ export class ClaudeCodeAdapter extends EngineAdapter {
return this.v2Sessions.has(sessionId) || this.sessionDirectories.has(sessionId);
}

/**
* Rename a Claude session via SDK. The codemux session ID is opaque to the
* SDK; the real on-disk session is keyed by ccSessionId (captured during
* the system init message).
*/
async renameSession(
sessionId: string,
title: string,
directory?: string,
engineMeta?: Record<string, unknown>,
): Promise<void> {
const ccSessionId =
this.sessionCcIds.get(sessionId) ??
(typeof engineMeta?.ccSessionId === "string"
? (engineMeta.ccSessionId as string)
: undefined);
if (!ccSessionId) {
claudeLog.debug(
`[Claude][${sessionId}] renameSession skipped — no ccSessionId yet`,
);
return;
}
try {
await sdkRenameSession(ccSessionId, title, directory ? { dir: directory } : undefined);
} catch (err) {
claudeLog.warn(`[Claude][${sessionId}] renameSession via SDK failed:`, err);
}
}

async getSession(sessionId: string): Promise<UnifiedSession | null> {
return null;
}
Expand Down Expand Up @@ -3413,8 +3443,6 @@ export class ClaudeCodeAdapter extends EngineAdapter {
// Emit final message
this.emit("message.updated", { sessionId: buffer.sessionId, message: finalMessage });

// Title updates are handled by EngineManager's applyTitleFallback()

// Clean up
this.messageBuffers.delete(sessionId);
for (const [key, part] of this.toolCallParts) {
Expand Down
19 changes: 19 additions & 0 deletions electron/main/engines/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,25 @@ export class CodexAdapter extends EngineAdapter {
this.clearSessionState(sessionId);
}

/** Push a renamed title to the Codex app-server via thread/name/set RPC. */
async renameSession(sessionId: string, title: string): Promise<void> {
const threadId = this.sessionToThread.get(sessionId);
if (!threadId || !this.client?.running) return;
try {
await this.client.request("thread/name/set", {
threadId,
threadName: title,
});
const thread = this.threads.get(threadId);
if (thread) {
thread.title = title || undefined;
thread.updatedAt = Date.now();
}
} catch (error) {
codexLog.warn(`Failed to set Codex thread name for ${threadId}:`, error);
}
}

async sendMessage(
sessionId: string,
content: MessagePromptContent[],
Expand Down
42 changes: 41 additions & 1 deletion electron/main/engines/copilot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { tmpdir } from "os";

import { timeId } from "../../utils/id-gen";
import { CopilotClient, CopilotSession } from "@github/copilot-sdk";
import { isPromptFallbackTitle } from "../../../../src/lib/session-utils";
import type {
SessionEvent,
SessionConfig,
Expand Down Expand Up @@ -200,6 +201,7 @@ export class CopilotSdkAdapter extends EngineAdapter {
private sessionModes = new Map<string, string>();
private sessionReasoningEfforts = new Map<string, ReasoningEffort>();
private sessionDirectories = new Map<string, string>();
private sessionTitles?: Map<string, string>;

private sessionTodos = new Map<string, Map<string, { id: string; title: string; status: string }>>();
// Fallback for permission prompts that still expose an "Always Allow" option but
Expand Down Expand Up @@ -457,6 +459,17 @@ export class CopilotSdkAdapter extends EngineAdapter {
this.allowedAlwaysKinds.delete(sessionId);
}

async renameSession(sessionId: string, title: string, directory?: string): Promise<void> {
const trimmed = title.trim();
if (!trimmed) return;
try {
const session = await this.ensureActiveSession(sessionId, directory);
await session.rpc.name.set({ name: trimmed.slice(0, 100) });
} catch (err) {
copilotLog.warn(`[Copilot][${sessionId}] renameSession failed:`, err);
}
}

async sendMessage(
sessionId: string,
content: MessagePromptContent[],
Expand Down Expand Up @@ -1428,10 +1441,37 @@ export class CopilotSdkAdapter extends EngineAdapter {
this.pendingUserMessages.delete(sessionId);
}
}

void this.refreshSessionTitle(sessionId);
}

private getFirstUserPrompt(sessionId: string): string | undefined {
const firstUser = this.messageHistory.get(sessionId)?.find((message) => message.role === "user");
const textPart = firstUser?.parts.find((part): part is TextPart => part.type === "text");
return textPart?.text;
}

private async refreshSessionTitle(sessionId: string): Promise<void> {
if (!this.client) return;
try {
const meta = await this.client.getSessionMetadata(sessionId);
const title = meta?.summary?.trim();
if (!title || isPromptFallbackTitle(title, this.getFirstUserPrompt(sessionId))) return;
const cached = this.sessionTitles?.get(sessionId);
if (cached === title) return;
if (!this.sessionTitles) this.sessionTitles = new Map();
this.sessionTitles.set(sessionId, title);
this.emit("session.updated", {
session: { id: sessionId, engineType: this.engineType, title },
});
} catch (err) {
copilotLog.debug(`[Copilot][${sessionId}] refreshSessionTitle failed:`, err);
}
}

private handleTitleChanged(sessionId: string, data: { title?: string }): void {
if (data.title) this.emit("session.updated", { session: { id: sessionId, engineType: this.engineType, title: data.title } });
const title = data.title?.trim();
if (title) this.emit("session.updated", { session: { id: sessionId, engineType: this.engineType, title } });
}

private handleSessionError(sessionId: string, data: { message?: string }): void {
Expand Down
17 changes: 17 additions & 0 deletions electron/main/engines/engine-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,23 @@ export abstract class EngineAdapter extends EventEmitter {
/** Delete a session */
abstract deleteSession(sessionId: string): Promise<void>;

/**
* Rename a session on the engine side. Default: no-op.
* Engines that persist titles (Claude SDK, Codex thread/name/set, OpenCode
* session.update) override this so codemux's local rename stays in sync
* with the engine's own session list.
*
* @param title Empty string clears any engine-side custom title.
*/
async renameSession(
_sessionId: string,
_title: string,
_directory?: string,
_engineMeta?: Record<string, unknown>,
): Promise<void> {
/* default: not supported */
}

// --- Messages ---

/**
Expand Down
49 changes: 38 additions & 11 deletions electron/main/engines/opencode/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ToolState as SdkToolState,
ProviderListResponse,
} from "@opencode-ai/sdk/v2";
import { isDefaultTitle } from "../../../../src/lib/session-utils";
import { normalizeToolName, inferToolKind } from "../../../../src/types/tool-mapping";
import type {
EngineType,
Expand All @@ -23,11 +24,13 @@ import type {
} from "../../../../src/types/unified";

export function convertSession(engineType: EngineType, sdk: SdkSession): UnifiedSession {
const title = sdk.title && !isDefaultTitle(sdk.title) ? sdk.title : undefined;

return {
id: sdk.id,
engineType,
directory: sdk.directory.replaceAll("\\", "/"),
title: sdk.title,
title,
parentId: sdk.parentID,
projectId: sdk.projectID,
time: {
Expand Down Expand Up @@ -192,34 +195,58 @@ function convertToolState(sdkState: SdkToolState): ToolState {
}
}

type OpenCodeModelCompat = {
capabilities?: {
temperature?: boolean;
reasoning?: boolean;
attachment?: boolean;
toolcall?: boolean;
tool_call?: boolean;
};
temperature?: boolean;
reasoning?: boolean;
attachment?: boolean;
toolcall?: boolean;
tool_call?: boolean;
cost?: {
input: number;
output: number;
cache?: { read?: number; write?: number };
cache_read?: number;
cache_write?: number;
};
};

export function convertProviders(engineType: EngineType, response: ProviderListResponse): UnifiedModelInfo[] {
const models: UnifiedModelInfo[] = [];
for (const provider of response.all) {
// Only include connected providers
if (!response.connected.includes(provider.id)) continue;

for (const model of Object.values(provider.models)) {
const capabilities = model.capabilities;
const compat = model as typeof model & OpenCodeModelCompat;
const capabilities = compat.capabilities ?? compat;
const cost = compat.cost;
models.push({
modelId: `${provider.id}/${model.id}`,
name: model.name,
description: `${model.family ?? ""} (${provider.name})`.trim(),
engineType,
providerId: provider.id,
providerName: provider.name,
cost: model.cost ? {
input: model.cost.input,
output: model.cost.output,
cost: cost ? {
input: cost.input,
output: cost.output,
cache: {
read: model.cost.cache.read ?? 0,
write: model.cost.cache.write ?? 0,
read: cost.cache?.read ?? cost.cache_read ?? 0,
write: cost.cache?.write ?? cost.cache_write ?? 0,
},
} : undefined,
capabilities: {
temperature: capabilities?.temperature ?? false,
reasoning: capabilities?.reasoning ?? false,
attachment: capabilities?.attachment ?? false,
toolcall: capabilities?.toolcall ?? false,
temperature: capabilities.temperature ?? false,
reasoning: capabilities.reasoning ?? false,
attachment: capabilities.attachment ?? false,
toolcall: capabilities.toolcall ?? capabilities.tool_call ?? false,
},
meta: {
status: model.status,
Expand Down
36 changes: 36 additions & 0 deletions electron/main/engines/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,25 @@ export class OpenCodeAdapter extends EngineAdapter {
for (const entry of entries) {
entry.resolve(finalMessage);
}

void this.refreshSessionTitle(sessionID);
}

private async refreshSessionTitle(sessionId: string): Promise<void> {
try {
const client = this.clientForSession(sessionId);
const result = await client.session.get({ sessionID: sessionId });
if (result.error || !result.data) return;
const sdkSession = result.data;
const cached = this.sessions.get(sessionId);
const session = convertSession(this.engineType, sdkSession);
if (session.title && session.title !== cached?.title) {
this.sessions.set(session.id, session);
this.emit("session.updated", { session });
}
} catch (err) {
openCodeLog.debug(`[OpenCode] refreshSessionTitle failed for ${sessionId}:`, err);
}
}

private handleSessionUpdated(sdkSession: SdkSession): void {
Expand Down Expand Up @@ -912,6 +931,23 @@ export class OpenCodeAdapter extends EngineAdapter {
this.userMessageIds.delete(sessionId);
}

/** Push a renamed title to OpenCode via session.update. */
async renameSession(sessionId: string, title: string, directory?: string): Promise<void> {
const session = this.sessions.get(sessionId);
const dir = directory ?? session?.directory;
const client = dir ? this.createClient(dir) : this.ensureClient();
try {
await client.session.update({
sessionID: sessionId,
...(dir ? { directory: dir } : {}),
title,
});
} catch (err) {
// Don't surface — local rename already succeeded
openCodeLog.warn(`session.update title failed for ${sessionId}:`, err);
}
}

// --- Messages ---

async sendMessage(
Expand Down
Loading
Loading