Skip to content
Closed
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
55 changes: 54 additions & 1 deletion src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isReasoningDelta,
isReasoningEnd,
} from "../types/ipc";
import { DEFAULT_MODEL } from "../constants/models";

// StreamingMessageAggregator is now imported from utils

Expand Down Expand Up @@ -127,7 +128,7 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,
message: string;
details?: string;
} | null>(null);
const [currentModel, setCurrentModel] = useState<string>("anthropic:claude-opus-4-1");
const [currentModel, setCurrentModel] = useState<string>(DEFAULT_MODEL);
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
undefined
);
Expand Down Expand Up @@ -175,6 +176,46 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,

const [loading, setLoading] = useState(false);

useEffect(() => {
let isMounted = true;

const loadMetadata = async () => {
try {
const metadata = await window.api.workspace.getInfo(workspaceId);
if (isMounted) {
setCurrentModel(metadata?.model ?? DEFAULT_MODEL);
}
} catch (error) {
console.error("Failed to load workspace metadata:", error);
if (isMounted) {
setCurrentModel(DEFAULT_MODEL);
}
}
};

if (workspaceId) {
void loadMetadata();
}

return () => {
isMounted = false;
};
}, [workspaceId]);

useEffect(() => {
const unsubscribe = window.api.workspace.onMetadata(({ workspaceId: updatedId, metadata }) => {
if (updatedId === workspaceId) {
setCurrentModel(metadata?.model ?? DEFAULT_MODEL);
}
});

return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [workspaceId]);

// Handlers for editing messages
const handleEditUserMessage = useCallback((messageId: string, content: string) => {
setEditingMessage({ id: messageId, content });
Expand Down Expand Up @@ -375,6 +416,17 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,
await window.api.workspace.clearHistory(workspaceId);
}, [workspaceId, getAggregator]);

const handleSetModel = useCallback(
async (model: string) => {
const result = await window.api.workspace.setModel(workspaceId, model);
if (!result.success) {
throw new Error(result.error);
}
setCurrentModel(model);
},
[workspaceId]
);

const handleProviderConfig = useCallback(
async (provider: string, keyPath: string[], value: string) => {
const result = await window.api.providers.setProviderConfig(provider, keyPath, value);
Expand Down Expand Up @@ -506,6 +558,7 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,
onMessageSent={handleMessageSent}
onClearHistory={handleClearHistory}
onProviderConfig={handleProviderConfig}
onSetModel={handleSetModel}
debugMode={debugMode}
onDebugModeChange={setDebugMode}
disabled={!projectName || !branch}
Expand Down
86 changes: 86 additions & 0 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { usePersistedState } from "../hooks/usePersistedState";
import { ThinkingSliderComponent } from "./ThinkingSlider";
import { useThinkingLevel } from "../hooks/useThinkingLevel";
import { getSlashCommandSuggestions, type SlashSuggestion } from "../utils/slashCommands";
import { getModelAliasEntries } from "../constants/models";

const InputSection = styled.div`
position: relative;
Expand All @@ -19,6 +20,21 @@ const InputSection = styled.div`
gap: 8px;
`;

const MODEL_ALIAS_ENTRIES = getModelAliasEntries();

const renderAliasList = () => (
<>
{MODEL_ALIAS_ENTRIES.map(({ alias, model }, index) => (
<React.Fragment key={alias}>
{alias}
{" -> "}
{model}
{index < MODEL_ALIAS_ENTRIES.length - 1 ? <br /> : null}
</React.Fragment>
))}
</>
);

const InputControls = styled.div`
display: flex;
gap: 10px;
Expand Down Expand Up @@ -93,6 +109,7 @@ export interface ChatInputProps {
onMessageSent?: () => void; // Optional callback after successful send
onClearHistory: () => Promise<void>;
onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise<void>;
onSetModel?: (model: string) => Promise<void>;
debugMode: boolean;
onDebugModeChange: (enabled: boolean) => void;
disabled?: boolean;
Expand Down Expand Up @@ -162,6 +179,48 @@ const createCommandToast = (parsed: ParsedCommand): Toast | null => {
),
};

case "model-help":
return {
id: Date.now().toString(),
type: "error",
title: "Model Command",
message: "Select the AI model for this workspace.",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/model &lt;provider&gt; &lt;model_name&gt;
<br />
/model &lt;alias&gt;
<br />
<br />
<SolutionLabel>Aliases:</SolutionLabel>
{renderAliasList()}
</>
),
};

case "model-invalid-input":
return {
id: Date.now().toString(),
type: "error",
title: "Invalid Model Input",
message: parsed.input
? `Could not interpret '${parsed.input}' as a model.`
: "Model command requires a provider and model or a known alias.",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/model &lt;provider&gt; &lt;model_name&gt;
<br />
/model &lt;alias&gt;
<br />
<br />
<SolutionLabel>Known Aliases:</SolutionLabel>
{renderAliasList()}
</>
),
};

case "unknown-command": {
const cmd = "/" + parsed.command + (parsed.subcommand ? " " + parsed.subcommand : "");
return {
Expand Down Expand Up @@ -237,6 +296,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onMessageSent,
onClearHistory,
onProviderConfig,
onSetModel,
debugMode,
onDebugModeChange,
disabled = false,
Expand Down Expand Up @@ -359,6 +419,32 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

if (parsed.type === "model-set" && onSetModel) {
setIsSending(true);
setInput("");

try {
await onSetModel(parsed.model);
setToast({
id: Date.now().toString(),
type: "success",
title: "Model Updated",
message: `Using ${parsed.model}`,
});
} catch (error) {
console.error("Failed to set model:", error);
setToast({
id: Date.now().toString(),
type: "error",
message: error instanceof Error ? error.message : "Failed to set model",
});
setInput(messageText);
} finally {
setIsSending(false);
}
return;
}

// Handle all other commands - show display toast
const commandToast = createCommandToast(parsed);
if (commandToast) {
Expand Down
1 change: 1 addition & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const IPC_CHANNELS = {
WORKSPACE_CLEAR_HISTORY: "workspace:clearHistory",
WORKSPACE_STREAM_HISTORY: "workspace:streamHistory",
WORKSPACE_GET_INFO: "workspace:getInfo",
WORKSPACE_SET_MODEL: "workspace:setModel",

// Dynamic channel prefixes
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
Expand Down
20 changes: 20 additions & 0 deletions src/constants/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const DEFAULT_MODEL = "anthropic:claude-opus-4-1" as const;

export const MODEL_ALIAS_MAP = {
opus: DEFAULT_MODEL,
sonnet: "anthropic:claude-sonnet-4",
} as const;

export type ModelAlias = keyof typeof MODEL_ALIAS_MAP;

export function resolveModelAlias(input: string): string | undefined {
const normalized = input.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return MODEL_ALIAS_MAP[normalized as ModelAlias];
}

export function getModelAliasEntries(): Array<{ alias: string; model: string }> {
return Object.entries(MODEL_ALIAS_MAP).map(([alias, model]) => ({ alias, model }));
}
3 changes: 2 additions & 1 deletion src/debug/costs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from "path";
import { getSessionDir } from "../config";
import { CmuxMessage } from "../types/message";
import { calculateTokenStats } from "../utils/tokenStatsCalculator";
import { DEFAULT_MODEL } from "../constants/models";

/**
* Debug command to display cost/token statistics for a workspace
Expand Down Expand Up @@ -34,7 +35,7 @@ export async function costsCommand(workspaceId: string) {

// Detect model from first assistant message
const firstAssistantMessage = messages.find((msg) => msg.role === "assistant");
const model = firstAssistantMessage?.metadata?.model || "anthropic:claude-opus-4-1";
const model = firstAssistantMessage?.metadata?.model || DEFAULT_MODEL;

// Calculate stats using shared logic
const stats = await calculateTokenStats(messages, model);
Expand Down
23 changes: 23 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ ipcMain.handle(
id: workspaceId,
projectName,
workspacePath: result.path,
model: aiService.getDefaultModel(),
};
await aiService.saveWorkspaceMetadata(workspaceId, metadata);

Expand Down Expand Up @@ -205,6 +206,28 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_GET_INFO, async (_event, workspaceId: stri
return result.success ? result.data : null;
});

ipcMain.handle(
IPC_CHANNELS.WORKSPACE_SET_MODEL,
async (_event, workspaceId: string, model: string) => {
const result = await aiService.setWorkspaceModel(workspaceId, model);
if (!result.success) {
return { success: false, error: result.error };
}

const metadataResult = await aiService.getWorkspaceMetadata(workspaceId);
if (metadataResult.success) {
mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, {
workspaceId,
metadata: metadataResult.data,
});
} else {
log.error("Failed to emit updated metadata:", metadataResult.error);
}

return { success: true, data: undefined };
}
);

ipcMain.handle(
IPC_CHANNELS.WORKSPACE_SEND_MESSAGE,
async (
Expand Down
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const api: IPCApi = {
clearHistory: (workspaceId) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_HISTORY, workspaceId),
getInfo: (workspaceId) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
setModel: (workspaceId, model) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SET_MODEL, workspaceId, model),

onChat: (workspaceId, callback) => {
const channel = getChatChannel(workspaceId);
Expand Down
Loading