diff --git a/scripts/update_vercel_docs.sh b/scripts/update_vercel_docs.sh index 0719394aff..d352f02430 100755 --- a/scripts/update_vercel_docs.sh +++ b/scripts/update_vercel_docs.sh @@ -18,6 +18,7 @@ cd "$TEMP_DIR" git init -q git remote add origin https://github.com/vercel/ai.git git config core.sparseCheckout true +mkdir -p .git/info echo "content/*" >.git/info/sparse-checkout git fetch --depth=1 origin main diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 0e36a9c97a..01d147dc7f 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -9,6 +9,7 @@ import type { SendMessageError as SendMessageErrorType } from "../types/errors"; import { usePersistedState } from "../hooks/usePersistedState"; import { ThinkingSliderComponent } from "./ThinkingSlider"; import { useThinkingLevel } from "../hooks/useThinkingLevel"; +import { getSlashCommandSuggestions, type SlashSuggestion } from "../utils/slashCommands"; const InputSection = styled.div` position: relative; @@ -248,7 +249,8 @@ export const ChatInput: React.FC = ({ const [input, setInput] = usePersistedState("input:" + workspaceId, ""); const [isSending, setIsSending] = useState(false); const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); - const [availableCommands] = useState([]); // Will be populated in future + const [commandSuggestions, setCommandSuggestions] = useState([]); + const [providerNames, setProviderNames] = useState([]); const [toast, setToast] = useState(null); const inputRef = useRef(null); const [thinkingLevel] = useThinkingLevel(); @@ -270,13 +272,37 @@ export const ChatInput: React.FC = ({ // Watch input for slash commands useEffect(() => { - setShowCommandSuggestions(input.startsWith("/") && availableCommands.length > 0); - }, [input, availableCommands]); + const suggestions = getSlashCommandSuggestions(input, { providerNames }); + setCommandSuggestions(suggestions); + setShowCommandSuggestions(suggestions.length > 0); + }, [input, providerNames]); + + // Load provider names for suggestions + useEffect(() => { + let isMounted = true; + + const loadProviders = async () => { + try { + const names = await window.api.providers.list(); + if (isMounted && Array.isArray(names)) { + setProviderNames(names); + } + } catch (error) { + console.error("Failed to load provider list:", error); + } + }; + + void loadProviders(); + + return () => { + isMounted = false; + }; + }, []); // Handle command selection const handleCommandSelect = useCallback( - (command: string) => { - setInput(`/${command} `); + (suggestion: SlashSuggestion) => { + setInput(suggestion.replacement); setShowCommandSuggestions(false); inputRef.current?.focus(); }, @@ -404,7 +430,11 @@ export const ChatInput: React.FC = ({ } // Don't handle keys if command suggestions are visible - if (showCommandSuggestions && COMMAND_SUGGESTION_KEYS.includes(e.key)) { + if ( + showCommandSuggestions && + commandSuggestions.length > 0 && + COMMAND_SUGGESTION_KEYS.includes(e.key) + ) { return; // Let CommandSuggestions handle it } @@ -424,9 +454,8 @@ export const ChatInput: React.FC = ({ setToast(null)} /> setShowCommandSuggestions(false)} isVisible={showCommandSuggestions} /> diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 00a2fd5d45..09680ce420 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -1,14 +1,14 @@ import React, { useState, useEffect } from "react"; import styled from "@emotion/styled"; +import type { SlashSuggestion } from "../utils/slashCommands"; // Export the keys that CommandSuggestions handles export const COMMAND_SUGGESTION_KEYS = ["Tab", "ArrowUp", "ArrowDown", "Escape"]; // Props interface interface CommandSuggestionsProps { - input: string; - availableCommands: string[]; - onSelectCommand: (command: string) => void; + suggestions: SlashSuggestion[]; + onSelectSuggestion: (suggestion: SlashSuggestion) => void; onDismiss: () => void; isVisible: boolean; } @@ -79,61 +79,36 @@ const HelperText = styled.div` // Main component export const CommandSuggestions: React.FC = ({ - input, - availableCommands, - onSelectCommand, + suggestions, + onSelectSuggestion, onDismiss, isVisible, }) => { - const [filteredCommands, setFilteredCommands] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); - // Command descriptions for built-in commands - const getDescription = (cmd: string): string => { - const descriptions: Record = { - clear: "Clear conversation and start fresh", - compact: "Compress conversation history", - context: "Show context usage information", - cost: "Show token usage and costs", - init: "Initialize or reinitialize session", - model: "Switch AI model", - help: "Show available commands", - }; - - return descriptions[cmd] || `/${cmd}`; - }; - - // Filter commands based on input + // Reset selection whenever suggestions change useEffect(() => { - if (input.startsWith("/")) { - const searchTerm = input.slice(1).toLowerCase(); - const filtered = availableCommands - .filter((cmd) => cmd.toLowerCase().startsWith(searchTerm)) - .slice(0, 10); - - setFilteredCommands(filtered); - setSelectedIndex(0); - } - }, [input, availableCommands]); + setSelectedIndex(0); + }, [suggestions]); // Handle keyboard navigation useEffect(() => { - if (!isVisible) return; + if (!isVisible || suggestions.length === 0) return; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); - setSelectedIndex((i) => (i + 1) % filteredCommands.length); + setSelectedIndex((i) => (i + 1) % suggestions.length); break; case "ArrowUp": e.preventDefault(); - setSelectedIndex((i) => (i - 1 + filteredCommands.length) % filteredCommands.length); + setSelectedIndex((i) => (i - 1 + suggestions.length) % suggestions.length); break; case "Tab": - if (!e.shiftKey && filteredCommands.length > 0) { + if (!e.shiftKey && suggestions.length > 0) { e.preventDefault(); - onSelectCommand(filteredCommands[selectedIndex]); + onSelectSuggestion(suggestions[selectedIndex]); } break; case "Escape": @@ -145,7 +120,7 @@ export const CommandSuggestions: React.FC = ({ document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isVisible, filteredCommands, selectedIndex, onSelectCommand, onDismiss]); + }, [isVisible, suggestions, selectedIndex, onSelectSuggestion, onDismiss]); // Click outside handler useEffect(() => { @@ -162,21 +137,21 @@ export const CommandSuggestions: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isVisible, onDismiss]); - if (!isVisible || filteredCommands.length === 0) { + if (!isVisible || suggestions.length === 0) { return null; } return ( - {filteredCommands.map((cmd, index) => ( + {suggestions.map((suggestion, index) => ( setSelectedIndex(index)} - onClick={() => onSelectCommand(cmd)} + onClick={() => onSelectSuggestion(suggestion)} > - /{cmd} - {getDescription(cmd)} + {suggestion.display} + {suggestion.description} ))} diff --git a/src/config.ts b/src/config.ts index b196455329..f207e21899 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,11 +5,6 @@ import * as os from "os"; import * as jsonc from "jsonc-parser"; import type { WorkspaceMetadata } from "./types/workspace"; -export const CONFIG_DIR = path.join(os.homedir(), ".cmux"); -const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); -const PROVIDERS_FILE = path.join(CONFIG_DIR, "providers.jsonc"); -export const SESSIONS_DIR = path.join(CONFIG_DIR, "sessions"); - export interface Workspace { branch: string; path: string; @@ -20,7 +15,7 @@ export interface ProjectConfig { workspaces: Workspace[]; } -export interface Config { +export interface ProjectsConfig { projects: Map; } @@ -32,164 +27,187 @@ export interface ProviderConfig { export type ProvidersConfig = Record; -export function load_config_or_default(): Config { - try { - if (fs.existsSync(CONFIG_FILE)) { - const data = fs.readFileSync(CONFIG_FILE, "utf-8"); - const parsed = JSON.parse(data) as { projects?: unknown }; - - // Config is stored as array of [path, config] pairs - if (parsed.projects && Array.isArray(parsed.projects)) { - const projectsMap = new Map( - parsed.projects as Array<[string, ProjectConfig]> - ); - return { - projects: projectsMap, - }; - } - } - } catch (error) { - console.error("Error loading config:", error); +/** + * Config - Centralized configuration management + * + * Encapsulates all config paths and operations, making them dependency-injectable + * and testable. Pass a custom rootDir for tests to avoid polluting ~/.cmux + */ +export class Config { + readonly rootDir: string; + readonly sessionsDir: string; + readonly srcDir: string; + private readonly configFile: string; + private readonly providersFile: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ?? path.join(os.homedir(), ".cmux"); + this.sessionsDir = path.join(this.rootDir, "sessions"); + this.srcDir = path.join(this.rootDir, "src"); + this.configFile = path.join(this.rootDir, "config.json"); + this.providersFile = path.join(this.rootDir, "providers.jsonc"); } - // Return default config - return { - projects: new Map(), - }; -} - -export function save_config(config: Config): void { - try { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); + loadConfigOrDefault(): ProjectsConfig { + try { + if (fs.existsSync(this.configFile)) { + const data = fs.readFileSync(this.configFile, "utf-8"); + const parsed = JSON.parse(data) as { projects?: unknown }; + + // Config is stored as array of [path, config] pairs + if (parsed.projects && Array.isArray(parsed.projects)) { + const projectsMap = new Map( + parsed.projects as Array<[string, ProjectConfig]> + ); + return { + projects: projectsMap, + }; + } + } + } catch (error) { + console.error("Error loading config:", error); } - const data = { - projects: Array.from(config.projects.entries()), + // Return default config + return { + projects: new Map(), }; - - fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); - } catch (error) { - console.error("Error saving config:", error); } -} - -export function getProjectName(projectPath: string): string { - return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; -} -export function getWorkspacePath(projectPath: string, branch: string): string { - const projectName = getProjectName(projectPath); - return path.join(CONFIG_DIR, "src", projectName, branch); -} - -/** - * Find a workspace path by project name and branch - * @returns The workspace path or null if not found - */ -export function findWorkspacePath(projectName: string, branch: string): string | null { - const config = load_config_or_default(); + saveConfig(config: ProjectsConfig): void { + try { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } - for (const [projectPath, project] of config.projects) { - const currentProjectName = path.basename(projectPath); + const data = { + projects: Array.from(config.projects.entries()), + }; - if (currentProjectName === projectName) { - const workspace = project.workspaces.find((w: Workspace) => w.branch === branch); - if (workspace) { - return workspace.path; - } + fs.writeFileSync(this.configFile, JSON.stringify(data, null, 2)); + } catch (error) { + console.error("Error saving config:", error); } } - return null; -} - -/** - * WARNING: Never try to derive workspace path from workspace ID! - * This is a code smell that leads to bugs. - * - * The workspace path should always: - * 1. Be stored in WorkspaceMetadata when the workspace is created - * 2. Be retrieved from WorkspaceMetadata when needed - * 3. Be passed through the call stack explicitly - * - * Parsing workspaceId strings to derive paths is fragile and error-prone. - * The workspace path is established when the git worktree is created, - * and that canonical path should be preserved and used throughout. - */ + private getProjectName(projectPath: string): string { + return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + } -/** - * Get the session directory for a specific workspace - */ -export function getSessionDir(workspaceId: string): string { - return path.join(SESSIONS_DIR, workspaceId); -} + getWorkspacePath(projectPath: string, branch: string): string { + const projectName = this.getProjectName(projectPath); + return path.join(this.srcDir, projectName, branch); + } -/** - * Get all workspace metadata by scanning sessions directory and loading metadata files - * This centralizes the logic for workspace discovery and metadata loading - */ -export async function getAllWorkspaceMetadata(): Promise< - Array<{ workspaceId: string; metadata: WorkspaceMetadata }> -> { - try { - // Scan sessions directory for workspace directories - await fsPromises.access(SESSIONS_DIR); - const entries = await fsPromises.readdir(SESSIONS_DIR, { withFileTypes: true }); - const workspaceIds = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); - - const workspaceMetadata: Array<{ workspaceId: string; metadata: WorkspaceMetadata }> = []; - - for (const workspaceId of workspaceIds) { - try { - const metadataPath = path.join(getSessionDir(workspaceId), "metadata.json"); - const data = await fsPromises.readFile(metadataPath, "utf-8"); - const metadata = JSON.parse(data) as WorkspaceMetadata; - workspaceMetadata.push({ workspaceId, metadata }); - } catch (error) { - // Skip workspaces with missing or invalid metadata - console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error); + /** + * Find a workspace path by project name and branch + * @returns The workspace path or null if not found + */ + findWorkspacePath(projectName: string, branch: string): string | null { + const config = this.loadConfigOrDefault(); + + for (const [projectPath, project] of config.projects) { + const currentProjectName = path.basename(projectPath); + + if (currentProjectName === projectName) { + const workspace = project.workspaces.find((w: Workspace) => w.branch === branch); + if (workspace) { + return workspace.path; + } } } - return workspaceMetadata; - } catch { - return []; // Sessions directory doesn't exist yet + return null; } -} -/** - * Load providers configuration from JSONC file - * Supports comments in JSONC format - */ -export function loadProvidersConfig(): ProvidersConfig | null { - try { - if (fs.existsSync(PROVIDERS_FILE)) { - const data = fs.readFileSync(PROVIDERS_FILE, "utf-8"); - return jsonc.parse(data) as ProvidersConfig; - } - } catch (error) { - console.error("Error loading providers config:", error); + /** + * WARNING: Never try to derive workspace path from workspace ID! + * This is a code smell that leads to bugs. + * + * The workspace path should always: + * 1. Be stored in WorkspaceMetadata when the workspace is created + * 2. Be retrieved from WorkspaceMetadata when needed + * 3. Be passed through the call stack explicitly + * + * Parsing workspaceId strings to derive paths is fragile and error-prone. + * The workspace path is established when the git worktree is created, + * and that canonical path should be preserved and used throughout. + */ + + /** + * Get the session directory for a specific workspace + */ + getSessionDir(workspaceId: string): string { + return path.join(this.sessionsDir, workspaceId); } - return null; -} + /** + * Get all workspace metadata by scanning sessions directory and loading metadata files + * This centralizes the logic for workspace discovery and metadata loading + */ + async getAllWorkspaceMetadata(): Promise< + Array<{ workspaceId: string; metadata: WorkspaceMetadata }> + > { + try { + // Scan sessions directory for workspace directories + await fsPromises.access(this.sessionsDir); + const entries = await fsPromises.readdir(this.sessionsDir, { withFileTypes: true }); + const workspaceIds = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + const workspaceMetadata: Array<{ workspaceId: string; metadata: WorkspaceMetadata }> = []; + + for (const workspaceId of workspaceIds) { + try { + const metadataPath = path.join(this.getSessionDir(workspaceId), "metadata.json"); + const data = await fsPromises.readFile(metadataPath, "utf-8"); + const metadata = JSON.parse(data) as WorkspaceMetadata; + workspaceMetadata.push({ workspaceId, metadata }); + } catch (error) { + // Skip workspaces with missing or invalid metadata + console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error); + } + } -/** - * Save providers configuration to JSONC file - * @param config The providers configuration to save - */ -export function saveProvidersConfig(config: ProvidersConfig): void { - try { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); + return workspaceMetadata; + } catch { + return []; // Sessions directory doesn't exist yet + } + } + + /** + * Load providers configuration from JSONC file + * Supports comments in JSONC format + */ + loadProvidersConfig(): ProvidersConfig | null { + try { + if (fs.existsSync(this.providersFile)) { + const data = fs.readFileSync(this.providersFile, "utf-8"); + return jsonc.parse(data) as ProvidersConfig; + } + } catch (error) { + console.error("Error loading providers config:", error); } - // Format with 2-space indentation for readability - const jsonString = JSON.stringify(config, null, 2); + return null; + } + + /** + * Save providers configuration to JSONC file + * @param config The providers configuration to save + */ + saveProvidersConfig(config: ProvidersConfig): void { + try { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } - // Add a comment header to the file - const contentWithComments = `// Providers configuration for cmux + // Format with 2-space indentation for readability + const jsonString = JSON.stringify(config, null, 2); + + // Add a comment header to the file + const contentWithComments = `// Providers configuration for cmux // Configure your AI providers here // Example: // { @@ -200,9 +218,13 @@ export function saveProvidersConfig(config: ProvidersConfig): void { // } ${jsonString}`; - fs.writeFileSync(PROVIDERS_FILE, contentWithComments); - } catch (error) { - console.error("Error saving providers config:", error); - throw error; // Re-throw to let caller handle + fs.writeFileSync(this.providersFile, contentWithComments); + } catch (error) { + console.error("Error saving providers config:", error); + throw error; // Re-throw to let caller handle + } } } + +// Default instance for application use +export const defaultConfig = new Config(); diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index 0c38d96413..4bcee245cf 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -13,6 +13,7 @@ export const IPC_CHANNELS = { // Provider channels PROVIDERS_SET_CONFIG: "providers:setConfig", + PROVIDERS_LIST: "providers:list", // Workspace channels WORKSPACE_LIST: "workspace:list", diff --git a/src/debug/costs.ts b/src/debug/costs.ts index a174ff9eef..4204c72c7d 100644 --- a/src/debug/costs.ts +++ b/src/debug/costs.ts @@ -1,6 +1,6 @@ import * as fs from "fs"; import * as path from "path"; -import { getSessionDir } from "../config"; +import { defaultConfig } from "../config"; import type { CmuxMessage } from "../types/message"; import { calculateTokenStats } from "../utils/tokenStatsCalculator"; @@ -12,7 +12,7 @@ export async function costsCommand(workspaceId: string) { console.log(`\n=== Cost Statistics for workspace: ${workspaceId} ===\n`); // Load chat history - const sessionDir = getSessionDir(workspaceId); + const sessionDir = defaultConfig.getSessionDir(workspaceId); const chatHistoryPath = path.join(sessionDir, "chat.jsonl"); if (!fs.existsSync(chatHistoryPath)) { diff --git a/src/debug/list-workspaces.ts b/src/debug/list-workspaces.ts index c0bb56d036..e1c9acbbbe 100644 --- a/src/debug/list-workspaces.ts +++ b/src/debug/list-workspaces.ts @@ -1,9 +1,9 @@ -import { load_config_or_default, findWorkspacePath } from "../config"; +import { defaultConfig } from "../config"; import * as path from "path"; import * as fs from "fs"; export function listWorkspacesCommand() { - const config = load_config_or_default(); + const config = defaultConfig.loadConfigOrDefault(); console.log("\n=== Configuration Debug ===\n"); console.log("Projects in config:", config.projects.size); @@ -32,7 +32,7 @@ export function listWorkspacesCommand() { ]; for (const test of testCases) { - const result = findWorkspacePath(test.project, test.branch); + const result = defaultConfig.findWorkspacePath(test.project, test.branch); console.log(`findWorkspacePath('${test.project}', '${test.branch}'):`); if (result) { console.log(` Found: ${result}`); diff --git a/src/git.ts b/src/git.ts index fb2571052d..7a9495936c 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,7 +2,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import * as fs from "fs"; import * as path from "path"; -import { getWorkspacePath } from "./config"; +import type { Config } from "./config"; const execAsync = promisify(exec); @@ -13,11 +13,12 @@ export interface WorktreeResult { } export async function createWorktree( + config: Config, projectPath: string, branchName: string ): Promise { try { - const workspacePath = getWorkspacePath(projectPath, branchName); + const workspacePath = config.getWorkspacePath(projectPath, branchName); // Create workspace directory if it doesn't exist if (!fs.existsSync(path.dirname(workspacePath))) { diff --git a/src/main.ts b/src/main.ts index ac08ace745..d539e50710 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,8 @@ import type { MenuItemConstructorOptions } from "electron"; import { app, BrowserWindow, ipcMain, dialog, Menu } from "electron"; import * as path from "path"; -import type { Config, ProjectConfig } from "./config"; -import { - load_config_or_default, - save_config, - getAllWorkspaceMetadata, - loadProvidersConfig, - saveProvidersConfig, -} from "./config"; +import type { ProjectConfig, ProjectsConfig } from "./config"; +import { Config } from "./config"; import { createWorktree, removeWorktree } from "./git"; import { AIService } from "./services/aiService"; import { HistoryService } from "./services/historyService"; @@ -27,10 +21,12 @@ import type { import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants"; import type { SendMessageError } from "./types/errors"; import type { StreamErrorMessage } from "./types/ipc"; +import type { ThinkingLevel } from "./types/thinking"; -const historyService = new HistoryService(); -const partialService = new PartialService(historyService); -const aiService = new AIService(historyService, partialService); +const config = new Config(); +const historyService = new HistoryService(config); +const partialService = new PartialService(config, historyService); +const aiService = new AIService(config, historyService, partialService); console.log("Main process starting..."); @@ -57,21 +53,26 @@ if (!gotTheLock) { let mainWindow: BrowserWindow | null = null; +const createUnknownSendMessageError = (raw: string): SendMessageError => ({ + type: "unknown", + raw, +}); + // Register IPC handlers before creating window ipcMain.handle(IPC_CHANNELS.CONFIG_LOAD, () => { - const config = load_config_or_default(); + const projectsConfig = config.loadConfigOrDefault(); return { - projects: Array.from(config.projects.entries()), + projects: Array.from(projectsConfig.projects.entries()), }; }); ipcMain.handle( IPC_CHANNELS.CONFIG_SAVE, (_event, configData: { projects: Array<[string, ProjectConfig]> }) => { - const config: Config = { + const projectsConfig: ProjectsConfig = { projects: new Map(configData.projects), }; - save_config(config); + config.saveConfig(projectsConfig); return true; } ); @@ -95,7 +96,7 @@ ipcMain.handle( IPC_CHANNELS.WORKSPACE_CREATE, async (_event, projectPath: string, branchName: string) => { // First create the git worktree - const result = await createWorktree(projectPath, branchName); + const result = await createWorktree(config, projectPath, branchName); if (result.success && result.path) { const projectName = @@ -127,13 +128,13 @@ ipcMain.handle( ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string) => { try { // Load current config - const config = load_config_or_default(); + const projectsConfig = config.loadConfigOrDefault(); // Find workspace path from config let workspacePath: string | null = null; let foundProjectPath: string | null = null; - for (const [projectPath, projectConfig] of config.projects.entries()) { + for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { const workspace = projectConfig.workspaces.find((w) => { const projectName = path.basename(projectPath); const wsId = `${projectName}-${w.branch}`; @@ -163,10 +164,10 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string // Update config to remove the workspace if (foundProjectPath && workspacePath) { - const projectConfig = config.projects.get(foundProjectPath); + const projectConfig = projectsConfig.projects.get(foundProjectPath); if (projectConfig) { projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); - save_config(config); + config.saveConfig(projectsConfig); } } @@ -189,7 +190,7 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string ipcMain.handle(IPC_CHANNELS.WORKSPACE_LIST, async () => { try { - const workspaceData = await getAllWorkspaceMetadata(); + const workspaceData = await config.getAllWorkspaceMetadata(); return workspaceData.map(({ metadata }) => metadata); } catch (error) { console.error("Failed to list workspaces:", error); @@ -209,7 +210,7 @@ ipcMain.handle( workspaceId: string, message: string, editMessageId?: string, - thinkingLevel?: string + thinkingLevel?: ThinkingLevel ) => { log.debug("sendMessage handler: Received", { workspaceId, @@ -228,10 +229,7 @@ ipcMain.handle( log.error("Failed to truncate history for edit:", truncateResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: truncateResult.error, - }, + error: createUnknownSendMessageError(truncateResult.error), }; } // Note: We don't send a clear event here. The aggregator will handle @@ -251,10 +249,7 @@ ipcMain.handle( log.error("Failed to append message to history:", appendResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: appendResult.error, - }, + error: createUnknownSendMessageError(appendResult.error), }; } @@ -273,10 +268,7 @@ ipcMain.handle( log.error("Failed to get conversation history:", historyResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: historyResult.error, - }, + error: createUnknownSendMessageError(historyResult.error), }; } @@ -287,8 +279,7 @@ ipcMain.handle( const streamResult = await aiService.streamMessage( historyResult.data, workspaceId, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - thinkingLevel as any // Cast to ThinkingLevel - validated by TypeScript in frontend + thinkingLevel ); log.debug("sendMessage handler: Stream completed"); return streamResult; @@ -315,15 +306,15 @@ ipcMain.handle( (_event, provider: string, keyPath: string[], value: string) => { try { // Load current providers config or create empty - const config = loadProvidersConfig() ?? {}; + const providersConfig = config.loadProvidersConfig() ?? {}; // Ensure provider exists - if (!config[provider]) { - config[provider] = {}; + if (!providersConfig[provider]) { + providersConfig[provider] = {}; } // Set nested property value - let current = config[provider] as Record; + let current = providersConfig[provider] as Record; for (let i = 0; i < keyPath.length - 1; i++) { const key = keyPath[i]; if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { @@ -332,13 +323,12 @@ ipcMain.handle( current = current[key] as Record; } - // Set the final value if (keyPath.length > 0) { current[keyPath[keyPath.length - 1]] = value; } // Save updated config - saveProvidersConfig(config); + config.saveProvidersConfig(providersConfig); return { success: true, data: undefined }; } catch (error) { @@ -348,34 +338,36 @@ ipcMain.handle( } ); +ipcMain.handle(IPC_CHANNELS.PROVIDERS_LIST, () => { + try { + const providersConfig = config.loadProvidersConfig() ?? {}; + return Object.keys(providersConfig); + } catch (error) { + log.error("Failed to list providers config:", error); + return []; + } +}); + // Handle subscription events for chat history -ipcMain.on( - `workspace:chat:subscribe`, - (_event, workspaceId: string) => - void (async () => { - const chatChannel = getChatChannel(workspaceId); - - // Emit current chat history immediately - const history = await historyService.getHistory(workspaceId); - if (history.success) { - // Merge partial.json with chat history for complete message list - const partial = await partialService.readPartial(workspaceId); - - // Send all history messages - history.data.forEach((msg) => { - mainWindow?.webContents.send(chatChannel, msg); - }); - - // If there's a partial message (interrupted stream), send it after history - if (partial) { - mainWindow?.webContents.send(chatChannel, partial); - } +ipcMain.on(`workspace:chat:subscribe`, (_event, workspaceId: string) => { + void (async () => { + const chatChannel = getChatChannel(workspaceId); + + const history = await historyService.getHistory(workspaceId); + if (history.success) { + for (const msg of history.data) { + mainWindow?.webContents.send(chatChannel, msg); } - // Send caught-up signal - mainWindow?.webContents.send(chatChannel, { type: "caught-up" }); - })() -); + const partial = await partialService.readPartial(workspaceId); + if (partial) { + mainWindow?.webContents.send(chatChannel, partial); + } + } + + mainWindow?.webContents.send(chatChannel, { type: "caught-up" }); + })(); +}); // Handle subscription events for metadata ipcMain.on( @@ -383,7 +375,7 @@ ipcMain.on( () => void (async () => { try { - const workspaceData = await getAllWorkspaceMetadata(); + const workspaceData = await config.getAllWorkspaceMetadata(); // Emit current metadata for each workspace for (const { workspaceId, metadata } of workspaceData) { diff --git a/src/preload.ts b/src/preload.ts index 1a9fe72ebf..d88c6fef0c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -35,6 +35,7 @@ const api: IPCApi = { providers: { setProviderConfig: (provider, keyPath, value) => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), }, workspace: { list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST), diff --git a/src/services/aiService.test.ts b/src/services/aiService.test.ts index 2ed20c9ea8..4a3ff2b4c9 100644 --- a/src/services/aiService.test.ts +++ b/src/services/aiService.test.ts @@ -6,14 +6,16 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { AIService } from "./aiService"; import { HistoryService } from "./historyService"; import { PartialService } from "./partialService"; +import { Config } from "../config"; describe("AIService", () => { let service: AIService; beforeEach(() => { - const historyService = new HistoryService(); - const partialService = new PartialService(historyService); - service = new AIService(historyService, partialService); + const config = new Config(); + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + service = new AIService(config, historyService, partialService); }); // Note: These tests are placeholders as Bun doesn't support Jest mocking diff --git a/src/services/aiService.ts b/src/services/aiService.ts index bbe916ab4e..2ee00926d1 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -9,7 +9,7 @@ import type { WorkspaceMetadata } from "../types/workspace"; import { WorkspaceMetadataSchema } from "../types/workspace"; import type { CmuxMessage } from "../types/message"; import { createCmuxMessage } from "../types/message"; -import { SESSIONS_DIR, getSessionDir, loadProvidersConfig } from "../config"; +import type { Config } from "../config"; import { StreamManager } from "./streamManager"; import type { SendMessageError } from "../types/errors"; import { getToolsForModel } from "../utils/tools"; @@ -35,9 +35,11 @@ export class AIService extends EventEmitter { private defaultModel = "anthropic:claude-opus-4-1"; // Default model string private readonly historyService: HistoryService; private readonly partialService: PartialService; + private readonly config: Config; - constructor(historyService: HistoryService, partialService: PartialService) { + constructor(config: Config, historyService: HistoryService, partialService: PartialService) { super(); + this.config = config; this.historyService = historyService; this.partialService = partialService; this.streamManager = new StreamManager(historyService, partialService); @@ -65,14 +67,14 @@ export class AIService extends EventEmitter { private async ensureSessionsDir(): Promise { try { - await fs.mkdir(SESSIONS_DIR, { recursive: true }); + await fs.mkdir(this.config.sessionsDir, { recursive: true }); } catch (error) { log.error("Failed to create sessions directory:", error); } } private getMetadataPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.METADATA_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.METADATA_FILE); } async getWorkspaceMetadata(workspaceId: string): Promise> { @@ -102,7 +104,7 @@ export class AIService extends EventEmitter { metadata: WorkspaceMetadata ): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const metadataPath = this.getMetadataPath(workspaceId); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); @@ -140,7 +142,7 @@ export class AIService extends EventEmitter { } // Load providers configuration - the ONLY source of truth - const providersConfig = loadProvidersConfig(); + const providersConfig = this.config.loadProvidersConfig(); const providerConfig = providersConfig?.[providerName]; if (!providerConfig) { @@ -322,7 +324,7 @@ export class AIService extends EventEmitter { async deleteWorkspace(workspaceId: string): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.rm(workspaceDir, { recursive: true, force: true }); return Ok(undefined); } catch (error) { diff --git a/src/services/historyService.test.ts b/src/services/historyService.test.ts new file mode 100644 index 0000000000..f222ee9d5e --- /dev/null +++ b/src/services/historyService.test.ts @@ -0,0 +1,507 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { HistoryService } from "./historyService"; +import { Config } from "../config"; +import { createCmuxMessage } from "../types/message"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; + +describe("HistoryService", () => { + let service: HistoryService; + let config: Config; + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory for test files + tempDir = path.join(os.tmpdir(), `cmux-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create a Config with the temp directory + config = new Config(tempDir); + service = new HistoryService(config); + }); + + afterEach(async () => { + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("getHistory", () => { + it("should return empty array when no history exists", async () => { + const result = await service.getHistory("workspace1"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + it("should read messages from chat.jsonl", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi there", { + historySequence: 1, + }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + JSON.stringify({ ...msg2, workspaceId }) + + "\n" + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe("msg1"); + expect(result.data[1].id).toBe("msg2"); + } + }); + + it("should skip malformed JSON lines", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + "invalid json line\n" + + JSON.stringify({ + ...createCmuxMessage("msg2", "user", "World", { historySequence: 1 }), + workspaceId, + }) + + "\n" + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe("msg1"); + expect(result.data[1].id).toBe("msg2"); + } + }); + + it("should handle empty lines in history file", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + "\n\n\n" // Extra empty lines + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe("msg1"); + } + }); + }); + + describe("appendToHistory", () => { + it("should create workspace directory if it doesn't exist", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + const workspaceDir = config.getSessionDir(workspaceId); + const exists = await fs + .access(workspaceDir) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + }); + + it("should assign historySequence to message without metadata", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should assign sequential historySequence numbers", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi"); + const msg3 = createCmuxMessage("msg3", "user", "How are you?"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(3); + expect(history.data[0].metadata?.historySequence).toBe(0); + expect(history.data[1].metadata?.historySequence).toBe(1); + expect(history.data[2].metadata?.historySequence).toBe(2); + } + }); + + it("should preserve existing historySequence if provided", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello", { historySequence: 5 }); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(5); + } + }); + + it("should update sequence counter when message has higher sequence", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 10 }); + const msg2 = createCmuxMessage("msg2", "user", "World"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(10); + expect(history.data[1].metadata?.historySequence).toBe(11); + } + }); + + it("should preserve other metadata fields", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello", { + timestamp: 123456, + model: "claude-opus-4", + tokens: 100, + }); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.timestamp).toBe(123456); + expect(history.data[0].metadata?.model).toBe("claude-opus-4"); + expect(history.data[0].metadata?.tokens).toBe(100); + expect(history.data[0].metadata?.historySequence).toBeDefined(); + } + }); + + it("should include workspaceId in persisted message", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const workspaceDir = config.getSessionDir(workspaceId); + const chatPath = path.join(workspaceDir, "chat.jsonl"); + const content = await fs.readFile(chatPath, "utf-8"); + const persisted = JSON.parse(content.trim()) as { + workspaceId: string; + id: string; + role: string; + }; + + expect(persisted.workspaceId).toBe(workspaceId); + }); + }); + + describe("updateHistory", () => { + it("should update message by historySequence", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + const updatedMsg = createCmuxMessage("msg1", "user", "Updated Hello", { + historySequence: history.data[0].metadata?.historySequence, + }); + + const result = await service.updateHistory(workspaceId, updatedMsg); + expect(result.success).toBe(true); + + const newHistory = await service.getHistory(workspaceId); + if (newHistory.success) { + expect(newHistory.data[0].parts[0]).toMatchObject({ + type: "text", + text: "Updated Hello", + }); + expect(newHistory.data[0].metadata?.historySequence).toBe(0); + } + } + }); + + it("should return error if message has no historySequence", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.updateHistory(workspaceId, msg); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("without historySequence"); + } + }); + + it("should return error if message with historySequence not found", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg1); + + const msg2 = createCmuxMessage("msg2", "user", "Not found", { historySequence: 99 }); + const result = await service.updateHistory(workspaceId, msg2); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("No message found"); + } + }); + + it("should preserve historySequence when updating", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + const originalSequence = history.data[0].metadata?.historySequence; + const updatedMsg = createCmuxMessage("msg1", "user", "Updated", { + historySequence: originalSequence, + }); + + await service.updateHistory(workspaceId, updatedMsg); + + const newHistory = await service.getHistory(workspaceId); + if (newHistory.success) { + expect(newHistory.data[0].metadata?.historySequence).toBe(originalSequence); + } + } + }); + }); + + describe("truncateAfterMessage", () => { + it("should remove message and all subsequent messages", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + const msg3 = createCmuxMessage("msg3", "user", "Third"); + const msg4 = createCmuxMessage("msg4", "assistant", "Fourth"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + await service.appendToHistory(workspaceId, msg4); + + const result = await service.truncateAfterMessage(workspaceId, "msg2"); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(1); + expect(history.data[0].id).toBe("msg1"); + } + }); + + it("should update sequence counter after truncation", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + const msg3 = createCmuxMessage("msg3", "user", "Third"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + + await service.truncateAfterMessage(workspaceId, "msg2"); + + // Append a new message and check its sequence + const msg4 = createCmuxMessage("msg4", "user", "New message"); + await service.appendToHistory(workspaceId, msg4); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(2); + expect(history.data[0].metadata?.historySequence).toBe(0); + expect(history.data[1].metadata?.historySequence).toBe(1); + } + }); + + it("should reset sequence counter when truncating all messages", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + await service.truncateAfterMessage(workspaceId, "msg1"); + + const msg3 = createCmuxMessage("msg3", "user", "New"); + await service.appendToHistory(workspaceId, msg3); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(1); + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should return error if message not found", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const result = await service.truncateAfterMessage(workspaceId, "nonexistent"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("not found"); + } + }); + }); + + describe("clearHistory", () => { + it("should delete chat.jsonl file", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const result = await service.clearHistory(workspaceId); + + expect(result.success).toBe(true); + + const workspaceDir = config.getSessionDir(workspaceId); + const chatPath = path.join(workspaceDir, "chat.jsonl"); + const exists = await fs + .access(chatPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it("should reset sequence counter", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg1); + await service.clearHistory(workspaceId); + + const msg2 = createCmuxMessage("msg2", "user", "New message"); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should succeed when clearing non-existent history", async () => { + const workspaceId = "workspace-no-history"; + + const result = await service.clearHistory(workspaceId); + + expect(result.success).toBe(true); + }); + + it("should reset sequence counter even when file doesn't exist", async () => { + const workspaceId = "workspace-no-history"; + + await service.clearHistory(workspaceId); + + const msg = createCmuxMessage("msg1", "user", "First"); + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + }); + + describe("sequence number initialization", () => { + it("should initialize sequence from existing history", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + // Manually create history with specific sequences + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi", { historySequence: 1 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + JSON.stringify({ ...msg2, workspaceId }) + + "\n" + ); + + // Create new service instance to ensure fresh initialization + const newService = new HistoryService(config); + + // Append a new message - should get sequence 2 + const msg3 = createCmuxMessage("msg3", "user", "How are you?"); + await newService.appendToHistory(workspaceId, msg3); + + const history = await newService.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(3); + expect(history.data[2].metadata?.historySequence).toBe(2); + } + }); + + it("should start from 0 for new workspace", async () => { + const workspaceId = "new-workspace"; + const msg = createCmuxMessage("msg1", "user", "First message"); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + }); +}); diff --git a/src/services/historyService.ts b/src/services/historyService.ts index be683f56d8..8198ec80c1 100644 --- a/src/services/historyService.ts +++ b/src/services/historyService.ts @@ -3,7 +3,7 @@ import * as path from "path"; import type { Result } from "../types/result"; import { Ok, Err } from "../types/result"; import type { CmuxMessage } from "../types/message"; -import { getSessionDir } from "../config"; +import type { Config } from "../config"; import { MutexMap } from "../utils/mutexMap"; /** @@ -20,9 +20,14 @@ export class HistoryService { private sequenceCounters = new Map(); // File operation locks per workspace to prevent race conditions private fileLocks = new MutexMap(); + private readonly config: Config; + + constructor(config: Config) { + this.config = config; + } private getChatHistoryPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.CHAT_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.CHAT_FILE); } /** @@ -113,7 +118,7 @@ export class HistoryService { message: CmuxMessage ): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const historyPath = this.getChatHistoryPath(workspaceId); diff --git a/src/services/log.ts b/src/services/log.ts index fc5ba8f80e..49dd64bd48 100644 --- a/src/services/log.ts +++ b/src/services/log.ts @@ -10,9 +10,9 @@ import * as fs from "fs"; import * as path from "path"; -import { CONFIG_DIR } from "../config"; +import { defaultConfig } from "../config"; -const DEBUG_OBJ_DIR = path.join(CONFIG_DIR, "debug_obj"); +const DEBUG_OBJ_DIR = path.join(defaultConfig.rootDir, "debug_obj"); /** * Check if debug mode is enabled diff --git a/src/services/partialService.ts b/src/services/partialService.ts index 32ae328f93..d3a0dc3925 100644 --- a/src/services/partialService.ts +++ b/src/services/partialService.ts @@ -3,7 +3,7 @@ import * as path from "path"; import type { Result } from "../types/result"; import { Ok, Err } from "../types/result"; import type { CmuxMessage } from "../types/message"; -import { getSessionDir } from "../config"; +import type { Config } from "../config"; import type { HistoryService } from "./historyService"; import { MutexMap } from "../utils/mutexMap"; @@ -28,13 +28,15 @@ export class PartialService { private readonly PARTIAL_FILE = "partial.json"; private readonly historyService: HistoryService; private readonly fileLocks = new MutexMap(); + private readonly config: Config; - constructor(historyService: HistoryService) { + constructor(config: Config, historyService: HistoryService) { + this.config = config; this.historyService = historyService; } private getPartialPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.PARTIAL_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.PARTIAL_FILE); } /** @@ -61,7 +63,7 @@ export class PartialService { async writePartial(workspaceId: string, message: CmuxMessage): Promise> { return this.fileLocks.withLock(workspaceId, async () => { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const partialPath = this.getPartialPath(workspaceId); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index b3e35ab6a5..f2434c8273 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -122,6 +122,7 @@ export interface IPCApi { keyPath: string[], value: string ): Promise>; + list(): Promise; }; workspace: { list(): Promise; diff --git a/src/utils/commandParser.ts b/src/utils/commandParser.ts index c02e70a003..89e691e770 100644 --- a/src/utils/commandParser.ts +++ b/src/utils/commandParser.ts @@ -11,6 +11,93 @@ export type ParsedCommand = | { type: "unknown-command"; command: string; subcommand?: string } | null; +export interface SlashCommandDefinition { + key: string; + description: string; + appendSpace?: boolean; + handler?: SlashCommandHandler; + children?: readonly SlashCommandDefinition[]; +} + +interface SlashCommandHandlerArgs { + definition: SlashCommandDefinition; + path: readonly SlashCommandDefinition[]; + remainingTokens: string[]; + cleanRemainingTokens: string[]; +} + +type SlashCommandHandler = (input: SlashCommandHandlerArgs) => ParsedCommand; + +const clearCommandDefinition: SlashCommandDefinition = { + key: "clear", + description: "Clear conversation history", + appendSpace: false, + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length > 0) { + return { + type: "unknown-command", + command: "clear", + subcommand: cleanRemainingTokens[0], + }; + } + + return { type: "clear" }; + }, +}; + +const providersSetCommandDefinition: SlashCommandDefinition = { + key: "set", + description: "Set a provider configuration value", + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length < 3) { + return { + type: "providers-missing-args", + subcommand: "set", + argCount: cleanRemainingTokens.length, + }; + } + + const [provider, key, ...valueParts] = cleanRemainingTokens; + const value = valueParts.join(" "); + const keyPath = key.split("."); + + return { + type: "providers-set", + provider, + keyPath, + value, + }; + }, +}; + +const providersCommandDefinition: SlashCommandDefinition = { + key: "providers", + description: "Configure AI provider settings", + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length === 0) { + return { type: "providers-help" }; + } + + return { + type: "providers-invalid-subcommand", + subcommand: cleanRemainingTokens[0] ?? "", + }; + }, + children: [providersSetCommandDefinition], +}; +const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ + clearCommandDefinition, + providersCommandDefinition, +]; + +const SLASH_COMMAND_DEFINITION_MAP = new Map( + SLASH_COMMAND_DEFINITIONS.map((definition) => [definition.key, definition]) +); + +export function getSlashCommandDefinitions(): readonly SlashCommandDefinition[] { + return SLASH_COMMAND_DEFINITIONS; +} + /** * Parse a raw command string into a structured command * @param input The raw command string (e.g., "/providers set anthropic apiKey sk-xxx") @@ -23,62 +110,57 @@ export function parseCommand(input: string): ParsedCommand { } // Remove leading slash and split by spaces (respecting quotes) - const parts = trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + const parts = (trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; if (parts.length === 0) { return null; } - const [command, subcommand, ...args] = parts; - const cleanArgs = args.map((arg) => arg.replace(/^"(.*)"$/, "$1")); // Remove surrounding quotes + const [commandKey, ...restTokens] = parts; + const definition = SLASH_COMMAND_DEFINITION_MAP.get(commandKey); - // Handle /clear command - if (command === "clear" && !subcommand) { - return { type: "clear" }; + if (!definition) { + return { + type: "unknown-command", + command: commandKey ?? "", + subcommand: restTokens[0], + }; } - // Handle /providers commands - if (command === "providers") { - // No subcommand - show help - if (!subcommand) { - return { type: "providers-help" }; - } + const path: SlashCommandDefinition[] = [definition]; + let remainingTokens = restTokens; - // Invalid subcommand - if (subcommand !== "set") { - return { - type: "providers-invalid-subcommand", - subcommand, - }; - } + while (remainingTokens.length > 0) { + const currentDefinition = path[path.length - 1]; + const children = currentDefinition.children ?? []; + const nextToken = remainingTokens[0]; + const nextDefinition = children.find((child) => child.key === nextToken); - // /providers set - check arguments - if (cleanArgs.length < 3) { - return { - type: "providers-missing-args", - subcommand: "set", - argCount: cleanArgs.length, - }; + if (!nextDefinition) { + break; } - // Valid /providers set command - const [provider, key, ...valueParts] = cleanArgs; - const value = valueParts.join(" "); // Join remaining parts as value (handles spaces) - const keyPath = key.split("."); // Split key by dots for nested path + path.push(nextDefinition); + remainingTokens = remainingTokens.slice(1); + } + const targetDefinition = path[path.length - 1]; + + if (!targetDefinition.handler) { return { - type: "providers-set", - provider, - keyPath, - value, + type: "unknown-command", + command: commandKey ?? "", + subcommand: remainingTokens[0], }; } - // Unknown command - return { - type: "unknown-command", - command: command ?? "", - subcommand, - }; + const cleanRemainingTokens = remainingTokens.map((token) => token.replace(/^"(.*)"$/, "$1")); + + return targetDefinition.handler({ + definition: targetDefinition, + path, + remainingTokens, + cleanRemainingTokens, + }); } /** diff --git a/src/utils/slashCommands.test.ts b/src/utils/slashCommands.test.ts new file mode 100644 index 0000000000..8614290712 --- /dev/null +++ b/src/utils/slashCommands.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test"; +import { getSlashCommandSuggestions } from "./slashCommands"; + +describe("getSlashCommandSuggestions", () => { + it("returns empty suggestions for non-commands", () => { + expect(getSlashCommandSuggestions("hello")).toEqual([]); + expect(getSlashCommandSuggestions("")).toEqual([]); + }); + + it("suggests top level commands when starting with slash", () => { + const suggestions = getSlashCommandSuggestions("/"); + const labels = suggestions.map((s) => s.display); + + expect(labels).toContain("/clear"); + expect(labels).toContain("/providers"); + }); + + it("filters top level commands by partial input", () => { + const suggestions = getSlashCommandSuggestions("/cl"); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].replacement).toBe("/clear"); + }); + + it("suggests provider subcommands", () => { + const suggestions = getSlashCommandSuggestions("/providers "); + expect(suggestions.map((s) => s.display)).toContain("set"); + }); + + it("suggests provider names after /providers set", () => { + const suggestions = getSlashCommandSuggestions("/providers set ", { + providerNames: ["anthropic"], + }); + const labels = suggestions.map((s) => s.display); + + expect(labels).toContain("anthropic"); + }); + + it("suggests provider keys after selecting a provider", () => { + const suggestions = getSlashCommandSuggestions("/providers set anthropic "); + const keys = suggestions.map((s) => s.display); + + expect(keys).toContain("apiKey"); + expect(keys).toContain("baseUrl"); + }); + + it("filters provider keys by partial input", () => { + const suggestions = getSlashCommandSuggestions("/providers set anthropic api", { + providerNames: ["anthropic"], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].display).toBe("apiKey"); + }); +}); diff --git a/src/utils/slashCommands.ts b/src/utils/slashCommands.ts new file mode 100644 index 0000000000..90014f8347 --- /dev/null +++ b/src/utils/slashCommands.ts @@ -0,0 +1,274 @@ +import { getSlashCommandDefinitions, type SlashCommandDefinition } from "./commandParser"; + +export interface SlashSuggestion { + id: string; + display: string; + description: string; + replacement: string; +} + +export interface SlashSuggestionContext { + providerNames?: string[]; +} + +interface SuggestionDefinition { + key: string; + description: string; + appendSpace?: boolean; +} + +const COMMAND_DEFINITIONS = getSlashCommandDefinitions(); + +const COMMAND_DEFINITION_MAP = new Map( + COMMAND_DEFINITIONS.map((definition) => [definition.key, definition]) +); + +const DEFAULT_PROVIDER_NAMES: SuggestionDefinition[] = [ + { + key: "anthropic", + description: "Anthropic (Claude) provider", + }, + { + key: "openai", + description: "OpenAI provider", + }, + { + key: "google", + description: "Google Gemini provider", + }, +]; + +const DEFAULT_PROVIDER_KEYS: Record = { + anthropic: [ + { + key: "apiKey", + description: "API key used when calling Anthropic", + }, + { + key: "baseUrl", + description: "Override Anthropic base URL", + }, + { + key: "baseUrl.scheme", + description: "Protocol to use for the base URL", + }, + ], + openai: [ + { + key: "apiKey", + description: "API key used when calling OpenAI", + }, + { + key: "baseUrl", + description: "Override OpenAI base URL", + }, + ], + google: [ + { + key: "apiKey", + description: "API key used when calling Google Gemini", + }, + ], + default: [ + { + key: "apiKey", + description: "API key required by the provider", + }, + { + key: "baseUrl", + description: "Override provider base URL", + }, + { + key: "baseUrl.scheme", + description: "Protocol to use for the base URL", + }, + ], +}; + +function filterAndMapSuggestions( + definitions: readonly T[], + partial: string, + build: (definition: T) => SlashSuggestion +): SlashSuggestion[] { + const normalizedPartial = partial.trim().toLowerCase(); + + return definitions + .filter((definition) => + normalizedPartial ? definition.key.toLowerCase().startsWith(normalizedPartial) : true + ) + .map((definition) => build(definition)); +} + +function buildTopLevelSuggestions(partial: string): SlashSuggestion[] { + return filterAndMapSuggestions(COMMAND_DEFINITIONS, partial, (definition) => { + const appendSpace = definition.appendSpace ?? true; + const replacement = `/${definition.key}${appendSpace ? " " : ""}`; + return { + id: `command:${definition.key}`, + display: `/${definition.key}`, + description: definition.description, + replacement, + }; + }); +} + +function buildSubcommandSuggestions( + commandDefinition: SlashCommandDefinition, + partial: string, + prefixTokens: string[] +): SlashSuggestion[] { + const subcommands = commandDefinition.children ?? []; + + return filterAndMapSuggestions(subcommands, partial, (definition) => { + const appendSpace = definition.appendSpace ?? true; + const replacementTokens = [...prefixTokens, definition.key]; + const replacementBase = `/${replacementTokens.join(" ")}`; + return { + id: `command:${replacementTokens.join(":")}`, + display: definition.key, + description: definition.description, + replacement: `${replacementBase}${appendSpace ? " " : ""}`, + }; + }); +} + +function dedupeDefinitions(definitions: readonly T[]): T[] { + const seen = new Set(); + const result: T[] = []; + + for (const definition of definitions) { + const key = definition.key.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(definition); + } + + return result; +} + +function buildProviderNameSuggestions( + partial: string, + providerNames: string[] | undefined +): SlashSuggestion[] { + const dynamicDefinitions = (providerNames ?? []).map((name) => ({ + key: name, + description: `${name} provider configuration`, + })); + + const combined = dedupeDefinitions([...dynamicDefinitions, ...DEFAULT_PROVIDER_NAMES]); + + return filterAndMapSuggestions(combined, partial, (definition) => ({ + id: `command:providers:set:${definition.key}`, + display: definition.key, + description: definition.description, + replacement: `/providers set ${definition.key} `, + })); +} + +function buildProviderKeySuggestions( + partial: string, + providerName: string | undefined +): SlashSuggestion[] { + const definitions = [ + ...(providerName && DEFAULT_PROVIDER_KEYS[providerName] + ? DEFAULT_PROVIDER_KEYS[providerName] + : []), + ...DEFAULT_PROVIDER_KEYS.default, + ]; + + const combined = dedupeDefinitions(definitions); + + return filterAndMapSuggestions(combined, partial, (definition) => ({ + id: `command:providers:set:${providerName}:${definition.key}`, + display: definition.key, + description: definition.description, + replacement: `/providers set ${providerName ?? ""} ${definition.key} `, + })); +} + +export function getSlashCommandSuggestions( + input: string, + context: SlashSuggestionContext = {} +): SlashSuggestion[] { + if (!input.startsWith("/")) { + return []; + } + + const remainder = input.slice(1); + if (remainder.startsWith(" ")) { + return []; + } + + const parts = remainder.split(/\s+/); + const tokens = parts.filter((part) => part.length > 0); + const hasTrailingSpace = remainder.endsWith(" ") || remainder.length === 0; + const completedTokens = hasTrailingSpace ? tokens : tokens.slice(0, -1); + const partialToken = hasTrailingSpace ? "" : (tokens[tokens.length - 1] ?? ""); + const stage = completedTokens.length; + + if (stage === 0) { + return buildTopLevelSuggestions(partialToken); + } + + const rootKey = completedTokens[0] ?? tokens[0]; + if (!rootKey) { + return []; + } + + const rootDefinition = COMMAND_DEFINITION_MAP.get(rootKey); + if (!rootDefinition) { + return []; + } + + const definitionPath: SlashCommandDefinition[] = [rootDefinition]; + let lastDefinition = rootDefinition; + + for (let i = 1; i < completedTokens.length; i++) { + const token = completedTokens[i]; + const nextDefinition = (lastDefinition.children ?? []).find((child) => child.key === token); + + if (!nextDefinition) { + break; + } + + definitionPath.push(nextDefinition); + lastDefinition = nextDefinition; + } + + const matchedDefinitionCount = definitionPath.length; + + if (stage <= matchedDefinitionCount) { + const definitionForSuggestions = definitionPath[Math.max(0, stage - 1)]; + + if (definitionForSuggestions && (definitionForSuggestions.children ?? []).length > 0) { + const prefixTokens = completedTokens.slice(0, stage); + return buildSubcommandSuggestions(definitionForSuggestions, partialToken, prefixTokens); + } + } + + if (definitionPath[0]?.key !== "providers") { + return []; + } + + const setDefinition = definitionPath[1]; + if (!setDefinition || setDefinition.key !== "set") { + return []; + } + + if (stage === 2) { + return buildProviderNameSuggestions(partialToken, context.providerNames); + } + + const providerName = completedTokens[2] ?? tokens[2]; + if (!providerName) { + return []; + } + + if (stage === 3) { + return buildProviderKeySuggestions(partialToken, providerName); + } + + return []; +}