diff --git a/README.md b/README.md index 09d1283b..dbc0a751 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Codex Mate is a local-first CLI + Web UI for unified management of: - Claude Code `CLAUDE.md` editing (writes to `~/.claude/CLAUDE.md`) - OpenClaw JSON5 profiles and workspace `AGENTS.md` - Local skills market for Codex / Claude Code (target switching, local skills management, cross-app import, ZIP distribution) -- Local Codex/Claude sessions (list/filter/export/delete) with Usage analytics overview +- Local Codex/Claude/Gemini CLI/CodeBuddy Code sessions (list/filter/export/delete) with Usage analytics overview - Plugins (Prompt templates): reusable templates with variables and one-click copy - Task orchestration: plan/queue/run/review local tasks @@ -57,12 +57,18 @@ It works on local files directly and does not require cloud hosting. The skills - OpenClaw JSON5 profile management **Session Management** -- Unified Codex + Claude session list +- Unified Codex + Claude + Gemini CLI + CodeBuddy Code session list +- Session locations (local-first, configurable): + - Codex: `~/.codex/sessions/*.jsonl` (or `$CODEX_HOME/sessions`, `$XDG_CONFIG_HOME/codex/sessions`) + - Claude: `~/.claude/projects/**/**/*.jsonl` (or `$CLAUDE_HOME/projects`, `$XDG_CONFIG_HOME/claude/projects`) + - Gemini: `~/.gemini/tmp/*/chats/*.json` (or `$GEMINI_HOME/tmp`, `$XDG_CONFIG_HOME/gemini/tmp`) + - CodeBuddy: `~/.codebuddy/projects/**/**/*.jsonl` (or `$CODEBUDDY_CODE_HOME_DIR/projects`) - Local session pinning with persistent pinned state and pinned-first ordering -- Keyword/source/cwd filters +- Keyword/source/cwd/role/time filters, plus shareable filter links +- Copy resume command (Codex/Gemini/CodeBuddy): `codex resume ` / `gemini -r ` / `codebuddy -r ` - Fast search UX: short-lived query result caching to avoid rescanning on each keystroke - Usage subview with 7d / 30d session trends, message trends, source share, and top paths -- Markdown export +- Markdown export (Web UI + `codexmate export-session`, supports `--session-id` or `--file`) - Session-level and message-level delete (supports batch), with a local recycle bin for restore/purge - Large-session preview optimization (fast tail preview path) @@ -176,6 +182,12 @@ npm install -g @mmmbuto/codex-cli-termux@latest # Claude Code npm install -g @anthropic-ai/claude-code + +# Gemini CLI +npm install -g @google/gemini-cli + +# CodeBuddy Code +npm install -g @tencent-ai/codebuddy-code ``` ### Run from source @@ -223,7 +235,7 @@ npm run reset 79 | `codexmate qwen [args...]` | Qwen CLI passthrough entrypoint | | `codexmate run [--host ] [--no-browser]` | Start Web UI | | `codexmate mcp serve [--read-only\|--allow-write]` | Start MCP stdio server | -| `codexmate export-session --source ...` | Export session to Markdown | +| `codexmate export-session --source ...` | Export session to Markdown | | `codexmate zip [--max:0-9]` / `codexmate unzip [out]` | Zip / unzip | | `codexmate unzip-ext [out] [--ext:suffix[,suffix...]] [--no-recursive]` | Extract files with target suffixes from ZIP files in a directory (default `.json`, recursive by default) | diff --git a/README.zh.md b/README.zh.md index 479a791d..44b1265d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -29,7 +29,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - Claude Code `CLAUDE.md` 编辑(写入 `~/.claude/CLAUDE.md`) - OpenClaw JSON5 配置与 Workspace `AGENTS.md` - Codex / Claude Code Skills 市场(安装目标切换、本地 skills 管理、跨应用导入、ZIP 分发) -- Codex / Claude 本地会话浏览、筛选、导出、删除与 Usage 统计概览 +- Codex / Claude / Gemini CLI / CodeBuddy Code 本地会话浏览、筛选、导出、删除与 Usage 统计概览 - 插件(提示词模板):模板复用、变量填写、一键复制 - 任务编排:规划 / 排队 / 执行 / 回看 @@ -58,12 +58,18 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - OpenClaw JSON5 配置方案管理 **会话管理** -- 同页查看 Codex 与 Claude 会话 +- 同页查看 Codex、Claude、Gemini CLI 与 CodeBuddy Code 会话 +- 会话来源与默认路径(本地优先,可通过环境变量覆盖): + - Codex:`~/.codex/sessions/*.jsonl`(或 `$CODEX_HOME/sessions`、`$XDG_CONFIG_HOME/codex/sessions`) + - Claude:`~/.claude/projects/**/**/*.jsonl`(或 `$CLAUDE_HOME/projects`、`$XDG_CONFIG_HOME/claude/projects`) + - Gemini:`~/.gemini/tmp/*/chats/*.json`(或 `$GEMINI_HOME/tmp`、`$XDG_CONFIG_HOME/gemini/tmp`) + - CodeBuddy:`~/.codebuddy/projects/**/**/*.jsonl`(或 `$CODEBUDDY_CODE_HOME_DIR/projects`) - 支持本地会话置顶,置顶状态持久化保存并优先排序显示 -- 关键词搜索、来源筛选、cwd 路径筛选 +- 关键词搜索、来源筛选、cwd/角色/时间筛选,并支持复制筛选链接 +- 复制恢复命令(Codex/Gemini/CodeBuddy):`codex resume ` / `gemini -r ` / `codebuddy -r ` - 搜索体验优化:短周期结果缓存,避免输入时重复扫描 - Usage 子页:近 7 天 / 近 30 天会话趋势、消息趋势、来源占比、高频路径 -- 会话导出 Markdown +- 会话导出 Markdown(Web UI + `codexmate export-session`,支持 `--session-id` 或 `--file`) - 会话与消息级删除(支持批量),并提供本地回收站用于恢复/彻底删除 - 大会话预览优化(快速 tail 预览路径) @@ -166,7 +172,7 @@ codexmate run > 安全提示:默认监听会在当前局域网暴露未鉴权的管理界面。若包含 API Key、provider 配置或 skills 管理,请仅在可信网络中使用;如需仅本机访问,可设置 `CODEXMATE_HOST=127.0.0.1` 或启动时传入 `--host 127.0.0.1`。 -### 安装 Codex CLI / Claude Code(可选) +### 安装 Codex CLI / Claude Code / Gemini CLI / CodeBuddy Code(可选) Codex Mate 支持透传调用官方 CLI(例如 `codexmate codex ...`),建议先安装: @@ -179,6 +185,12 @@ npm install -g @mmmbuto/codex-cli-termux@latest # Claude Code npm install -g @anthropic-ai/claude-code + +# Gemini CLI +npm install -g @google/gemini-cli + +# CodeBuddy Code +npm install -g @tencent-ai/codebuddy-code ``` ### 从源码运行 @@ -225,7 +237,7 @@ npm run reset 79 | `codexmate qwen [args...]` | Qwen CLI 透传入口 | | `codexmate run [--host ] [--no-browser]` | 启动 Web UI | | `codexmate mcp serve [--read-only\|--allow-write]` | 启动 MCP stdio 服务 | -| `codexmate export-session --source ...` | 导出会话为 Markdown | +| `codexmate export-session --source ...` | 导出会话为 Markdown | | `codexmate zip [--max:0-9]` / `codexmate unzip [out]` | 压缩 / 解压 | | `codexmate unzip-ext [out] [--ext:suffix[,suffix...]] [--no-recursive]` | 批量提取目录下 ZIP 内指定后缀文件(默认 `.json`,默认递归) | diff --git a/cli.js b/cli.js index c0b5302c..8ebc5cf3 100644 --- a/cli.js +++ b/cli.js @@ -193,6 +193,10 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json'); const CLAUDE_MD_FILE_NAME = 'CLAUDE.md'; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); +const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy'); +const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects'); +const GEMINI_DIR = path.join(os.homedir(), '.gemini'); +const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp'); const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json'); const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json'); const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl'); @@ -272,6 +276,18 @@ const CLI_INSTALL_TARGETS = Object.freeze([ packageName: '@anthropic-ai/claude-code', bins: ['claude'] }, + { + id: 'codebuddy', + name: 'CodeBuddy Code', + packageName: '@tencent-ai/codebuddy-code', + bins: ['codebuddy'] + }, + { + id: 'gemini', + name: 'Gemini CLI', + packageName: '@google/gemini-cli', + bins: ['gemini'] + }, { id: 'codex', name: 'Codex CLI', @@ -569,7 +585,9 @@ let g_sessionListCache = new Map(); let g_sessionInventoryCache = new Map(); let g_sessionFileLookupCache = { codex: new Map(), - claude: new Map() + claude: new Map(), + gemini: new Map(), + codebuddy: new Map() }; let g_exactMessageCountCache = new Map(); let g_modelsCache = new Map(); @@ -1264,6 +1282,31 @@ function getClaudeProjectsDir() { return resolveExistingDir(candidates, CLAUDE_PROJECTS_DIR); } +function getGeminiTmpDir() { + const candidates = []; + const envGeminiHome = process.env.GEMINI_HOME; + if (envGeminiHome) { + candidates.push(path.join(envGeminiHome, 'tmp')); + } + const xdgConfig = process.env.XDG_CONFIG_HOME; + if (xdgConfig) { + candidates.push(path.join(xdgConfig, 'gemini', 'tmp')); + } + candidates.push(path.join(os.homedir(), '.config', 'gemini', 'tmp')); + candidates.push(GEMINI_TMP_DIR); + return resolveExistingDir(candidates, GEMINI_TMP_DIR); +} + +function getCodeBuddyProjectsDir() { + const candidates = []; + const envHome = process.env.CODEBUDDY_CODE_HOME_DIR || process.env.CODEBUDDY_HOME; + if (envHome) { + candidates.push(path.join(envHome, 'projects')); + } + candidates.push(CODEBUDDY_PROJECTS_DIR); + return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR); +} + function readModelsCacheEntry(cacheKey) { if (!cacheKey) return null; const entry = g_modelsCache.get(cacheKey); @@ -2511,6 +2554,19 @@ function countConversationMessagesInRecords(records, source) { } continue; } + if (source === 'codebuddy') { + if (record && record.type === 'message') { + const role = normalizeRole(record.role); + if (role === 'assistant' || role === 'user' || role === 'system') { + const content = record.message?.content ?? record.content ?? ''; + messages.push({ + role, + text: extractMessageText(content) + }); + } + } + continue; + } const role = normalizeRole(record.type); if (role === 'assistant' || role === 'user' || role === 'system') { @@ -2532,6 +2588,28 @@ async function countConversationMessagesInFile(filePath, source) { return cached; } + if (source === 'gemini') { + let json; + try { + json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + json = null; + } + const rawMessages = json && Array.isArray(json.messages) ? json.messages : []; + const messages = []; + for (const entry of rawMessages) { + if (!entry || typeof entry !== 'object') continue; + const role = normalizeGeminiMessageRole(entry.type); + if (!role) continue; + const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text)); + if (!text && role !== 'system') continue; + messages.push({ role, text }); + } + const safeCount = removeLeadingSystemMessage(messages).length; + writeExactMessageCountCache(filePath, source, safeCount, fileStat); + return safeCount; + } + let stream; let rl; let messageCount = 0; @@ -2559,6 +2637,15 @@ async function countConversationMessagesInFile(filePath, source) { role = normalizeRole(record.payload.role); text = extractMessageText(record.payload.content); } + } else if (source === 'codebuddy') { + if (record && record.type === 'message') { + role = normalizeRole(record.role); + if (role === 'assistant' || role === 'user' || role === 'system') { + text = extractMessageText(record.message?.content ?? record.content ?? ''); + } else { + role = ''; + } + } } else { role = normalizeRole(record.type); if (role === 'assistant' || role === 'user' || role === 'system') { @@ -2721,7 +2808,13 @@ async function resolveSessionTrashEntryExactMessageCount(entry) { } async function hydrateSessionTrashEntries(entries, options = {}) { - const source = options.source === 'claude' ? 'claude' : (options.source === 'codex' ? 'codex' : 'all'); + const source = options.source === 'claude' + ? 'claude' + : (options.source === 'codex' + ? 'codex' + : (options.source === 'gemini' + ? 'gemini' + : (options.source === 'codebuddy' ? 'codebuddy' : 'all'))); const hydratedEntries = await mapWithConcurrency(Array.isArray(entries) ? entries : [], 8, async (entry) => { const normalizedEntry = normalizeSessionTrashEntry(entry); if (!normalizedEntry) { @@ -2730,7 +2823,7 @@ async function hydrateSessionTrashEntries(entries, options = {}) { return await resolveSessionTrashEntryExactMessageCount(normalizedEntry); }); - if (source === 'codex' || source === 'claude') { + if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy') { return hydratedEntries.filter((entry) => entry.source === source); } return hydratedEntries; @@ -2744,7 +2837,11 @@ async function hydrateSessionItemsExactMessageCount(items) { if (item.__messageCountExact === true) { return item; } - const source = item.source === 'claude' ? 'claude' : (item.source === 'codex' ? 'codex' : ''); + const source = item.source === 'claude' + ? 'claude' + : (item.source === 'codex' + ? 'codex' + : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : ''))); const filePath = typeof item.filePath === 'string' ? item.filePath : ''; if (!source || !filePath || !fs.existsSync(filePath)) { return item; @@ -2971,6 +3068,54 @@ async function scanSessionContentForQuery(session, tokens, options = {}) { ? Math.max(1024, rawMaxBytes) : 0; const state = createSessionQueryScanState(tokens, options); + if (session.source === 'gemini') { + if (state.roleFilter !== 'all') { + let json; + try { + json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + json = null; + } + const rawMessages = json && Array.isArray(json.messages) ? json.messages : []; + for (const entry of rawMessages) { + if (!entry || typeof entry !== 'object') continue; + const role = normalizeGeminiMessageRole(entry.type); + if (!role) continue; + const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text)); + if (!text) continue; + if (consumeSessionQueryMessage(state, { role, text })) { + break; + } + } + return buildSessionQueryScanResult(state); + } + + let text = ''; + try { + const stat = fs.statSync(filePath); + const targetBytes = maxBytes > 0 ? Math.min(maxBytes, stat.size || 0) : Math.min(stat.size || 0, 512 * 1024); + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(targetBytes); + const bytes = fs.readSync(fd, buf, 0, targetBytes, 0); + fs.closeSync(fd); + text = bytes > 0 ? buf.slice(0, bytes).toString('utf-8') : ''; + } catch (_) { + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch (_) { + text = ''; + } + } + + if (!matchTokensInText(text, state.tokens, state.mode)) { + return buildSessionQueryScanResult(state); + } + state.count = 1; + if (state.snippetLimit > 0) { + state.snippets.push(truncateText(text)); + } + return buildSessionQueryScanResult(state); + } let stream; let rl; try { @@ -3253,7 +3398,9 @@ function getSessionInventoryCache(cacheKey, forceRefresh = false) { } function registerSessionFileLookupEntries(source, sessions = []) { - const normalizedSource = source === 'claude' ? 'claude' : 'codex'; + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + ? source + : 'codex'; const store = g_sessionFileLookupCache[normalizedSource]; if (!(store instanceof Map) || !Array.isArray(sessions)) { return; @@ -3292,7 +3439,9 @@ function setSessionInventoryCache(cacheKey, source, value) { } function listSessionInventoryBySource(source, limit, scanOptions = {}, options = {}) { - const normalizedSource = source === 'claude' ? 'claude' : 'codex'; + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + ? source + : 'codex'; const forceRefresh = !!options.forceRefresh; const cacheKey = buildSessionInventoryCacheKey(normalizedSource, limit, scanOptions); const cached = getSessionInventoryCache(cacheKey, forceRefresh); @@ -3302,7 +3451,11 @@ function listSessionInventoryBySource(source, limit, scanOptions = {}, options = const sessions = normalizedSource === 'claude' ? listClaudeSessions(limit, scanOptions) - : listCodexSessions(limit, scanOptions); + : (normalizedSource === 'gemini' + ? listGeminiSessions(limit, scanOptions) + : (normalizedSource === 'codebuddy' + ? listCodeBuddySessions(limit, scanOptions) + : listCodexSessions(limit, scanOptions))); setSessionInventoryCache(cacheKey, normalizedSource, sessions); return sessions; } @@ -3312,7 +3465,9 @@ function invalidateSessionListCache() { g_sessionInventoryCache.clear(); g_sessionFileLookupCache = { codex: new Map(), - claude: new Map() + claude: new Map(), + gemini: new Map(), + codebuddy: new Map() }; } @@ -3904,6 +4059,317 @@ function parseClaudeSessionSummary(filePath, options = {}) { }; } +function parseCodeBuddySessionSummary(filePath, options = {}) { + const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes)) + ? Math.max(1024, Math.floor(Number(options.summaryReadBytes))) + : SESSION_SUMMARY_READ_BYTES; + const titleReadBytes = Number.isFinite(Number(options.titleReadBytes)) + ? Math.max(1024, Math.floor(Number(options.titleReadBytes))) + : SESSION_TITLE_READ_BYTES; + const records = parseJsonlHeadRecords(filePath, summaryReadBytes); + if (records.length === 0) { + return null; + } + + let stat; + try { + stat = fs.statSync(filePath); + } catch (_) { + return null; + } + + let sessionId = path.basename(filePath, '.jsonl'); + let cwd = ''; + let firstPrompt = ''; + let messageCount = 0; + let totalTokens = 0; + let contextWindow = 0; + let inputTokens = 0; + let cachedInputTokens = 0; + let outputTokens = 0; + let reasoningOutputTokens = 0; + let provider = 'codebuddy'; + let model = ''; + const models = []; + const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens }; + const previewMessages = []; + let createdAt = ''; + let updatedAt = stat.mtime.toISOString(); + + for (const record of records) { + if (!createdAt && record && record.timestamp) { + createdAt = toIsoTime(record.timestamp, createdAt); + } + if (record && record.timestamp) { + updatedAt = updateLatestIso(updatedAt, record.timestamp); + } + + applySessionUsageSummaryFromRecord(usageState, record, 'codebuddy'); + totalTokens = usageState.totalTokens || 0; + contextWindow = usageState.contextWindow || 0; + inputTokens = usageState.inputTokens || 0; + cachedInputTokens = usageState.cachedInputTokens || 0; + outputTokens = usageState.outputTokens || 0; + reasoningOutputTokens = usageState.reasoningOutputTokens || 0; + + if (record && typeof record.sessionId === 'string' && record.sessionId.trim()) { + sessionId = record.sessionId.trim(); + } + if (!cwd && record && typeof record.cwd === 'string' && record.cwd.trim()) { + cwd = record.cwd.trim(); + } + + provider = readExplicitSessionProviderFromRecord(record) || provider; + const recordModels = readSessionModelsFromRecord(record); + for (const recordModel of recordModels) { + if (!models.includes(recordModel)) { + models.push(recordModel); + } + } + model = recordModels[0] || model; + + if (record && record.type === 'message') { + const role = normalizeRole(record.role); + if (role === 'assistant' || role === 'user' || role === 'system') { + const content = record.message?.content ?? record.content ?? ''; + previewMessages.push({ + role, + text: extractMessageText(content) + }); + } + } + } + + const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes); + for (const record of tailRecords) { + applySessionUsageSummaryFromRecord(usageState, record, 'codebuddy'); + totalTokens = usageState.totalTokens || 0; + contextWindow = usageState.contextWindow || 0; + inputTokens = usageState.inputTokens || 0; + cachedInputTokens = usageState.cachedInputTokens || 0; + outputTokens = usageState.outputTokens || 0; + reasoningOutputTokens = usageState.reasoningOutputTokens || 0; + provider = readExplicitSessionProviderFromRecord(record) || provider; + const recordModels = readSessionModelsFromRecord(record); + for (const recordModel of recordModels) { + if (!models.includes(recordModel)) { + models.push(recordModel); + } + } + model = recordModels[0] || model; + } + + const filteredPreviewMessages = removeLeadingSystemMessage(previewMessages); + messageCount = filteredPreviewMessages.length; + const firstUser = filteredPreviewMessages.find(item => item.role === 'user' && item.text); + if (firstUser) { + firstPrompt = truncateText(firstUser.text); + } + + if (!firstPrompt) { + const titleRecords = parseJsonlHeadRecords(filePath, titleReadBytes); + const titleMessages = []; + for (const record of titleRecords) { + if (record && record.type === 'message') { + const role = normalizeRole(record.role); + if (role === 'assistant' || role === 'user' || role === 'system') { + const content = record.message?.content ?? record.content ?? ''; + titleMessages.push({ + role, + text: extractMessageText(content) + }); + } + } + } + + const filteredTitleMessages = removeLeadingSystemMessage(titleMessages); + const titleUser = filteredTitleMessages.find(item => item.role === 'user' && item.text); + if (titleUser) { + firstPrompt = truncateText(titleUser.text); + } + } + + messageCount = Math.max(0, messageCount); + + return { + source: 'codebuddy', + sourceLabel: 'CodeBuddy Code', + provider, + model, + models, + sessionId, + title: firstPrompt || sessionId, + cwd, + createdAt, + updatedAt, + messageCount, + totalTokens, + contextWindow, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes), + filePath, + keywords: [], + capabilities: { code: true } + }; +} + +function extractGeminiMessageText(content) { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + const parts = []; + for (const item of content) { + if (!item) continue; + if (typeof item === 'string') { + parts.push(item); + continue; + } + if (typeof item.text === 'string' && item.text.trim()) { + parts.push(item.text); + continue; + } + if (typeof item.content === 'string' && item.content.trim()) { + parts.push(item.content); + } + } + return parts.filter(Boolean).join('\n'); + } + if (content && typeof content === 'object') { + if (typeof content.text === 'string') { + return content.text; + } + if (typeof content.content === 'string') { + return content.content; + } + if (Array.isArray(content.parts)) { + return extractGeminiMessageText(content.parts); + } + if (Array.isArray(content.content)) { + return extractGeminiMessageText(content.content); + } + } + return ''; +} + +function normalizeGeminiMessageRole(type) { + const t = typeof type === 'string' ? type.trim().toLowerCase() : ''; + if (t === 'user') return 'user'; + if (t === 'gemini' || t === 'assistant' || t === 'model') return 'assistant'; + if (t === 'system' || t === 'info' || t === 'warning' || t === 'error') return 'system'; + return ''; +} + +function parseGeminiSessionSummary(filePath, options = {}) { + const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes)) + ? Math.max(1024, Math.floor(Number(options.summaryReadBytes))) + : SESSION_SUMMARY_READ_BYTES; + const titleReadBytes = Number.isFinite(Number(options.titleReadBytes)) + ? Math.max(1024, Math.floor(Number(options.titleReadBytes))) + : SESSION_TITLE_READ_BYTES; + let stat; + try { + stat = fs.statSync(filePath); + } catch (_) { + return null; + } + + const fileName = path.basename(filePath); + const projectHash = path.basename(path.dirname(path.dirname(filePath))); + let sessionId = path.basename(filePath, '.json'); + let createdAt = ''; + let updatedAt = stat.mtime.toISOString(); + let provider = 'gemini'; + let model = ''; + const models = []; + let firstPrompt = ''; + let messageCount = 0; + + let headText = ''; + try { + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(summaryReadBytes); + const bytes = fs.readSync(fd, buf, 0, summaryReadBytes, 0); + fs.closeSync(fd); + headText = bytes > 0 ? buf.slice(0, bytes).toString('utf-8') : ''; + } catch (_) { + headText = ''; + } + + if (headText) { + const sessionIdMatch = headText.match(/"sessionId"\s*:\s*"([^"]+)"/); + if (sessionIdMatch) { + sessionId = sessionIdMatch[1] || sessionId; + } + const startMatch = headText.match(/"startTime"\s*:\s*"([^"]+)"/); + if (startMatch) { + createdAt = toIsoTime(startMatch[1], createdAt); + } + const updatedMatch = headText.match(/"lastUpdated"\s*:\s*"([^"]+)"/); + if (updatedMatch) { + updatedAt = toIsoTime(updatedMatch[1], updatedAt); + } + const modelMatch = headText.match(/"model"\s*:\s*"([^"]+)"/); + if (modelMatch && modelMatch[1]) { + model = modelMatch[1]; + models.push(model); + } + const summaryMatch = headText.match(/"summary"\s*:\s*"([^"]+)"/); + if (summaryMatch && summaryMatch[1]) { + firstPrompt = truncateText(summaryMatch[1]); + } + if (!firstPrompt) { + const userIdx = headText.search(/"type"\s*:\s*"user"/); + if (userIdx >= 0) { + const slice = headText.slice(userIdx, Math.min(headText.length, userIdx + titleReadBytes)); + const contentStringMatch = slice.match(/"content"\s*:\s*"((?:\\\\.|[^\"\\\\])*)"/); + const textMatch = slice.match(/"text"\s*:\s*"((?:\\\\.|[^\"\\\\])*)"/); + const raw = (contentStringMatch && contentStringMatch[1]) || (textMatch && textMatch[1]) || ''; + if (raw) { + try { + firstPrompt = truncateText(JSON.parse(`"${raw}"`)); + } catch (_) { + firstPrompt = truncateText(raw); + } + } + } + } + } + + if (!createdAt) { + createdAt = stat.mtime.toISOString(); + } + + const cwd = projectHash ? path.join(getGeminiTmpDir(), projectHash) : ''; + + return { + source: 'gemini', + sourceLabel: 'Gemini CLI', + provider, + model, + models, + sessionId, + title: firstPrompt || sessionId || fileName, + cwd, + createdAt, + updatedAt, + messageCount, + totalTokens: 0, + contextWindow: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + __messageCountExact: false, + filePath, + keywords: [], + capabilities: { code: true } + }; +} + function listCodexSessions(limit, options = {}) { const codexSessionsDir = getCodexSessionsDir(); const scanFactor = Number.isFinite(Number(options.scanFactor)) @@ -4154,8 +4620,144 @@ function listClaudeSessions(limit, options = {}) { return mergeAndLimitSessions(sessions, limit); } +function listGeminiSessions(limit, options = {}) { + const geminiTmpDir = getGeminiTmpDir(); + if (!fs.existsSync(geminiTmpDir)) { + return []; + } + + const scanFactor = Number.isFinite(Number(options.scanFactor)) + ? Math.max(1, Number(options.scanFactor)) + : SESSION_SCAN_FACTOR; + const minFiles = Number.isFinite(Number(options.minFiles)) + ? Math.max(1, Number(options.minFiles)) + : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR); + const targetCount = Number.isFinite(Number(options.targetCount)) + ? Math.max(1, Math.floor(Number(options.targetCount))) + : Math.max(1, Math.floor(limit * scanFactor)); + const scanCount = Number.isFinite(Number(options.scanCount)) + ? Math.max(targetCount, Math.floor(Number(options.scanCount))) + : Math.max(targetCount, minFiles); + const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned)) + ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned))) + : Math.max(scanCount * 2, minFiles); + const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes)) + ? Math.max(1024, Math.floor(Number(options.summaryReadBytes))) + : SESSION_SUMMARY_READ_BYTES; + const titleReadBytes = Number.isFinite(Number(options.titleReadBytes)) + ? Math.max(1024, Math.floor(Number(options.titleReadBytes))) + : SESSION_TITLE_READ_BYTES; + + const sessions = []; + const filesMeta = []; + let scanned = 0; + let projectDirs = []; + try { + projectDirs = fs.readdirSync(geminiTmpDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(geminiTmpDir, entry.name)); + } catch (_) { + projectDirs = []; + } + + for (const projectDir of projectDirs) { + const chatsDir = path.join(projectDir, 'chats'); + if (!fs.existsSync(chatsDir)) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(chatsDir, { withFileTypes: true }); + } catch (_) { + entries = []; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + const fullPath = path.join(chatsDir, entry.name); + try { + const stat = fs.statSync(fullPath); + filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 }); + } catch (_) {} + scanned += 1; + if (scanned >= maxFilesScanned) { + break; + } + } + if (scanned >= maxFilesScanned) { + break; + } + } + + filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const item of filesMeta.slice(0, scanCount)) { + const summary = parseGeminiSessionSummary(item.filePath, { summaryReadBytes, titleReadBytes }); + if (summary) { + sessions.push(summary); + } + if (sessions.length >= targetCount) { + break; + } + } + + return mergeAndLimitSessions(sessions, limit); +} + +function listCodeBuddySessions(limit, options = {}) { + const projectsDir = getCodeBuddyProjectsDir(); + if (!fs.existsSync(projectsDir)) { + return []; + } + + const scanFactor = Number.isFinite(Number(options.scanFactor)) + ? Math.max(1, Number(options.scanFactor)) + : SESSION_SCAN_FACTOR; + const minFiles = Number.isFinite(Number(options.minFiles)) + ? Math.max(1, Number(options.minFiles)) + : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR); + const targetCount = Number.isFinite(Number(options.targetCount)) + ? Math.max(1, Math.floor(Number(options.targetCount))) + : Math.max(1, Math.floor(limit * scanFactor)); + const scanCount = Number.isFinite(Number(options.scanCount)) + ? Math.max(targetCount, Math.floor(Number(options.scanCount))) + : Math.max(targetCount, minFiles); + const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned)) + ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned))) + : Math.max(scanCount * 2, minFiles); + const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes)) + ? Math.max(1024, Math.floor(Number(options.summaryReadBytes))) + : SESSION_SUMMARY_READ_BYTES; + const titleReadBytes = Number.isFinite(Number(options.titleReadBytes)) + ? Math.max(1024, Math.floor(Number(options.titleReadBytes))) + : SESSION_TITLE_READ_BYTES; + + const files = collectRecentJsonlFiles(projectsDir, { + returnCount: scanCount, + maxFilesScanned, + ignoreSubPath: `${path.sep}subagents${path.sep}` + }); + const sessions = []; + for (const filePath of files) { + if (path.basename(filePath) === 'history.jsonl') { + continue; + } + const summary = parseCodeBuddySessionSummary(filePath, { + summaryReadBytes, + titleReadBytes + }); + if (summary) { + sessions.push(summary); + } + if (sessions.length >= targetCount) { + break; + } + } + return mergeAndLimitSessions(sessions, limit); +} + async function listAllSessions(params = {}) { - const source = params.source === 'codex' || params.source === 'claude' + const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy' ? params.source : 'all'; const rawLimit = Number(params.limit); @@ -4199,6 +4801,12 @@ async function listAllSessions(params = {}) { if (source === 'all' || source === 'claude') { sessions = sessions.concat(listSessionInventoryBySource('claude', limit, scanOptions, { forceRefresh })); } + if (source === 'all' || source === 'gemini') { + sessions = sessions.concat(listSessionInventoryBySource('gemini', limit, scanOptions, { forceRefresh })); + } + if (source === 'all' || source === 'codebuddy') { + sessions = sessions.concat(listSessionInventoryBySource('codebuddy', limit, scanOptions, { forceRefresh })); + } if (hasPathFilter) { sessions = sessions.filter(item => matchesSessionPathFilter(item, normalizedPathFilter)); @@ -4279,6 +4887,8 @@ async function listSessionUsage(params = {}) { listSessionBrowse, parseCodexSessionSummary, parseClaudeSessionSummary, + parseCodeBuddySessionSummary, + parseGeminiSessionSummary, MAX_SESSION_USAGE_LIST_SIZE, SESSION_BROWSE_SUMMARY_READ_BYTES }); @@ -4286,10 +4896,10 @@ async function listSessionUsage(params = {}) { function listSessionPaths(params = {}) { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'all') { + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { return []; } - const validSource = source === 'codex' || source === 'claude' ? source : 'all'; + const validSource = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' ? source : 'all'; const rawLimit = Number(params.limit); const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, MAX_SESSION_PATH_LIST_SIZE)) @@ -4317,6 +4927,12 @@ function listSessionPaths(params = {}) { if (validSource === 'all' || validSource === 'claude') { sessions = sessions.concat(listSessionInventoryBySource('claude', gatherLimit, scanOptions, { forceRefresh })); } + if (validSource === 'all' || validSource === 'gemini') { + sessions = sessions.concat(listSessionInventoryBySource('gemini', gatherLimit, scanOptions, { forceRefresh })); + } + if (validSource === 'all' || validSource === 'codebuddy') { + sessions = sessions.concat(listSessionInventoryBySource('codebuddy', gatherLimit, scanOptions, { forceRefresh })); + } const dedupedPaths = []; const seen = new Set(); @@ -4342,7 +4958,14 @@ function listSessionPaths(params = {}) { } function resolveSessionFilePath(source, filePath, sessionId) { - const root = source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir(); + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + ? source + : 'codex'; + const root = normalizedSource === 'claude' + ? getClaudeProjectsDir() + : (normalizedSource === 'gemini' + ? getGeminiTmpDir() + : (normalizedSource === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir())); if (!root || !fs.existsSync(root)) { return ''; } @@ -4357,7 +4980,7 @@ function resolveSessionFilePath(source, filePath, sessionId) { if (typeof sessionId === 'string' && sessionId.trim()) { const targetId = sessionId.trim().toLowerCase(); - const lookupStore = g_sessionFileLookupCache[source === 'claude' ? 'claude' : 'codex']; + const lookupStore = g_sessionFileLookupCache[normalizedSource]; if (lookupStore instanceof Map && lookupStore.has(targetId)) { const cachedPath = lookupStore.get(targetId); if (cachedPath && fs.existsSync(cachedPath) && isPathInside(cachedPath, root)) { @@ -4365,8 +4988,39 @@ function resolveSessionFilePath(source, filePath, sessionId) { } lookupStore.delete(targetId); } - const files = collectJsonlFiles(root, 5000); - const matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId); + let matchedFile = ''; + if (normalizedSource === 'gemini') { + const filesMeta = []; + let projectDirs = []; + try { + projectDirs = fs.readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(root, entry.name)); + } catch (_) { + projectDirs = []; + } + for (const projectDir of projectDirs) { + const chatsDir = path.join(projectDir, 'chats'); + if (!fs.existsSync(chatsDir)) continue; + let entries = []; + try { + entries = fs.readdirSync(chatsDir, { withFileTypes: true }); + } catch (_) { + entries = []; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const fullPath = path.join(chatsDir, entry.name); + filesMeta.push(fullPath); + if (filesMeta.length >= 5000) break; + } + if (filesMeta.length >= 5000) break; + } + matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || ''; + } else { + const files = collectJsonlFiles(root, 5000); + matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId) || ''; + } if (matchedFile && fs.existsSync(matchedFile)) { return matchedFile; } @@ -4543,11 +5197,15 @@ function moveFileSync(sourcePath, targetPath) { function buildSessionSummaryFallback(source, filePath, sessionId = '') { const resolvedSessionId = sessionId || path.basename(filePath, '.jsonl'); - const sourceLabel = source === 'claude' ? 'Claude Code' : 'Codex'; + const sourceLabel = source === 'claude' + ? 'Claude Code' + : (source === 'gemini' ? 'Gemini CLI' : (source === 'codebuddy' ? 'CodeBuddy Code' : 'Codex')); return { source, sourceLabel, - provider: source === 'claude' ? 'claude' : 'codex', + provider: source === 'claude' + ? 'claude' + : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : 'codex')), sessionId: resolvedSessionId, title: resolvedSessionId, cwd: '', @@ -4558,7 +5216,7 @@ function buildSessionSummaryFallback(source, filePath, sessionId = '') { contextWindow: 0, filePath, keywords: [], - capabilities: source === 'claude' ? { code: true } : {} + capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' ? { code: true } : {} }; } @@ -4569,11 +5227,14 @@ function generateSessionTrashId() { return `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; } -function allocateSessionTrashTarget() { +function allocateSessionTrashTarget(extension = 'jsonl') { ensureDir(SESSION_TRASH_FILES_DIR); + const safeExt = typeof extension === 'string' && extension.trim() + ? extension.trim().replace(/^\./, '') + : 'jsonl'; for (let attempt = 0; attempt < 6; attempt += 1) { const trashId = generateSessionTrashId(); - const trashFileName = `${trashId}.jsonl`; + const trashFileName = `${trashId}.${safeExt}`; const trashFilePath = path.join(SESSION_TRASH_FILES_DIR, trashFileName); if (!fs.existsSync(trashFilePath)) { return { trashId, trashFileName, trashFilePath }; @@ -4582,8 +5243,8 @@ function allocateSessionTrashTarget() { const fallbackId = `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; return { trashId: fallbackId, - trashFileName: `${fallbackId}.jsonl`, - trashFilePath: path.join(SESSION_TRASH_FILES_DIR, `${fallbackId}.jsonl`) + trashFileName: `${fallbackId}.${safeExt}`, + trashFilePath: path.join(SESSION_TRASH_FILES_DIR, `${fallbackId}.${safeExt}`) }; } @@ -4591,7 +5252,13 @@ function normalizeSessionTrashEntry(entry) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return null; } - const source = entry.source === 'claude' ? 'claude' : (entry.source === 'codex' ? 'codex' : ''); + const source = entry.source === 'claude' + ? 'claude' + : (entry.source === 'codex' + ? 'codex' + : (entry.source === 'gemini' + ? 'gemini' + : (entry.source === 'codebuddy' ? 'codebuddy' : ''))); const trashId = typeof entry.trashId === 'string' ? entry.trashId.trim() : ''; if (!source || !trashId || trashId.includes('/') || trashId.includes('\\') || trashId.includes('\0')) { return null; @@ -4606,7 +5273,9 @@ function normalizeSessionTrashEntry(entry) { trashId, trashFileName, source, - sourceLabel: source === 'claude' ? 'Claude Code' : 'Codex', + sourceLabel: source === 'claude' + ? 'Claude Code' + : (source === 'gemini' ? 'Gemini CLI' : (source === 'codebuddy' ? 'CodeBuddy Code' : 'Codex')), sessionId: sessionId || trashId, title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : (sessionId || trashId), cwd: typeof entry.cwd === 'string' ? entry.cwd : '', @@ -4622,7 +5291,7 @@ function normalizeSessionTrashEntry(entry) { originalFilePath: typeof entry.originalFilePath === 'string' ? entry.originalFilePath : '', provider: typeof entry.provider === 'string' && entry.provider.trim() ? entry.provider.trim() - : (source === 'claude' ? 'claude' : 'codex'), + : (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : 'codex'))), keywords: normalizeKeywords(entry.keywords), capabilities: normalizeCapabilities(entry.capabilities), claudeIndexPath: typeof entry.claudeIndexPath === 'string' ? entry.claudeIndexPath : '', @@ -4740,7 +5409,11 @@ function resolveSessionRestoreTarget(entry) { if (!normalized) { return ''; } - const root = normalized.source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir(); + const root = normalized.source === 'claude' + ? getClaudeProjectsDir() + : (normalized.source === 'gemini' + ? getGeminiTmpDir() + : (normalized.source === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir())); const originalFilePath = typeof normalized.originalFilePath === 'string' ? normalized.originalFilePath.trim() : ''; if (!root || !originalFilePath) { return ''; @@ -4874,14 +5547,20 @@ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) { } async function listSessionTrashItems(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : 'all'); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : 'all'))); const countOnly = params.countOnly === true; const rawLimit = Number(params.limit); const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, MAX_SESSION_TRASH_LIST_SIZE)) : 200; const allEntries = readSessionTrashEntries(); - let items = source === 'codex' || source === 'claude' + let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' ? allEntries.filter((entry) => entry.source === source) : allEntries.slice(); items.sort((a, b) => { @@ -5061,7 +5740,13 @@ async function purgeSessionTrashItems(params = {}) { } async function trashSessionData(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : ''); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : ''))); if (!source) { return { error: 'Invalid source' }; } @@ -5071,14 +5756,18 @@ async function trashSessionData(params = {}) { return { error: 'Session file not found' }; } - const summary = (source === 'claude' ? parseClaudeSessionSummary(filePath) : parseCodexSessionSummary(filePath)) + const summary = (source === 'claude' + ? parseClaudeSessionSummary(filePath) + : (source === 'gemini' + ? parseGeminiSessionSummary(filePath) + : (source === 'codebuddy' ? parseCodeBuddySessionSummary(filePath) : parseCodexSessionSummary(filePath)))) || buildSessionSummaryFallback(source, filePath, params.sessionId); const exactMessageCount = await countConversationMessagesInFile(filePath, source); if (Number.isFinite(Number(exactMessageCount))) { summary.messageCount = Math.max(0, Math.floor(Number(exactMessageCount))); } - const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, '.jsonl'); - const { trashId, trashFileName, trashFilePath } = allocateSessionTrashTarget(); + const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl'); + const { trashId, trashFileName, trashFilePath } = allocateSessionTrashTarget(source === 'gemini' ? 'json' : 'jsonl'); const deletedAt = new Date().toISOString(); const claudeIndexPath = source === 'claude' ? findClaudeSessionIndexPath(filePath) : ''; let removedClaudeIndexEntry = null; @@ -5162,7 +5851,13 @@ async function trashSessionData(params = {}) { } async function deleteSessionData(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : ''); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : ''))); if (!source) { return { error: 'Invalid source' }; } @@ -5172,7 +5867,7 @@ async function deleteSessionData(params = {}) { return { error: 'Session file not found' }; } - const sessionId = params.sessionId || path.basename(filePath, '.jsonl'); + const sessionId = params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl'); let fileDeleted = false; try { fs.unlinkSync(filePath); @@ -5497,6 +6192,38 @@ function extractClaudeMessageFromRecord(record, state, lineIndex = -1) { } } +function extractCodeBuddyMessageFromRecord(record, state, lineIndex = -1) { + if (record && record.timestamp) { + state.updatedAt = toIsoTime(record.timestamp, state.updatedAt); + } + + if (record && typeof record.sessionId === 'string' && record.sessionId.trim()) { + state.sessionId = record.sessionId.trim(); + } + + if (!state.cwd && record && typeof record.cwd === 'string' && record.cwd.trim()) { + state.cwd = record.cwd.trim(); + } + + if (!record || record.type !== 'message') { + return; + } + + const role = normalizeRole(record.role); + if (role === 'user' || role === 'assistant' || role === 'system') { + const content = record.message?.content ?? record.content ?? ''; + const text = extractMessageText(content); + if (text && canAppendMessage(state)) { + state.messages.push({ + role, + text, + timestamp: toIsoTime(record.timestamp, ''), + recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1 + }); + } + } +} + function recordHasCodexMessage(record) { if (!record || record.type !== 'response_item' || !record.payload) { return false; @@ -5525,10 +6252,23 @@ function recordHasClaudeMessage(record) { return !!text; } +function recordHasCodeBuddyMessage(record) { + if (!record || record.type !== 'message') { + return false; + } + const role = normalizeRole(record.role); + if (role !== 'user' && role !== 'assistant' && role !== 'system') { + return false; + } + const content = record.message?.content ?? record.content ?? ''; + const text = extractMessageText(content); + return !!text; +} + function recordHasMessage(record, source) { - return source === 'codex' - ? recordHasCodexMessage(record) - : recordHasClaudeMessage(record); + if (source === 'codex') return recordHasCodexMessage(record); + if (source === 'codebuddy') return recordHasCodeBuddyMessage(record); + return recordHasClaudeMessage(record); } function extractMessagesFromRecords(records, source, options = {}) { @@ -5546,6 +6286,8 @@ function extractMessagesFromRecords(records, source, options = {}) { const record = records[lineIndex]; if (source === 'codex') { extractCodexMessageFromRecord(record, state, lineIndex); + } else if (source === 'codebuddy') { + extractCodeBuddyMessageFromRecord(record, state, lineIndex); } else { extractClaudeMessageFromRecord(record, state, lineIndex); } @@ -5607,6 +6349,8 @@ async function extractMessagesFromFile(filePath, source, options = {}) { if (source === 'codex') { extractCodexMessageFromRecord(record, state, currentLineIndex); + } else if (source === 'codebuddy') { + extractCodeBuddyMessageFromRecord(record, state, currentLineIndex); } else { extractClaudeMessageFromRecord(record, state, currentLineIndex); } @@ -5631,7 +6375,13 @@ async function extractMessagesFromFile(filePath, source, options = {}) { } async function readSessionDetail(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : ''); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : ''))); if (!source) { return { error: 'Invalid source' }; } @@ -5648,9 +6398,52 @@ async function readSessionDetail(params = {}) { : DEFAULT_SESSION_DETAIL_MESSAGES; const preview = params.preview === true || params.preview === 'true'; - const extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); - const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl'); - const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code'; + let extracted; + if (source === 'gemini') { + let json; + try { + json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + json = null; + } + if (!json || typeof json !== 'object') { + return { error: 'Failed to parse session file' }; + } + const rawMessages = Array.isArray(json.messages) ? json.messages : []; + const messages = []; + for (const entry of rawMessages) { + if (!entry || typeof entry !== 'object') continue; + const role = normalizeGeminiMessageRole(entry.type); + if (!role) continue; + const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text)); + if (!text && role !== 'system') continue; + messages.push({ + role, + text, + timestamp: toIsoTime(entry.timestamp ?? entry.time ?? entry.at, '') + }); + } + const filtered = removeLeadingSystemMessage(messages); + const totalMessages = filtered.length; + const clipped = totalMessages > messageLimit; + const sliced = clipped ? filtered.slice(Math.max(0, totalMessages - messageLimit)) : filtered; + extracted = { + sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'), + cwd: typeof json.projectRoot === 'string' ? json.projectRoot : (typeof json.cwd === 'string' ? json.cwd : ''), + updatedAt: toIsoTime(json.lastUpdated ?? json.updatedAt, ''), + totalMessages, + clipped, + messages: sliced + }; + } else { + extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + } + const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl'); + const sourceLabel = source === 'codex' + ? 'Codex' + : (source === 'claude' + ? 'Claude Code' + : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); const clippedMessages = Array.isArray(extracted.messages) ? extracted.messages : []; const hasExactTotalMessages = Number.isFinite(extracted.totalMessages); const startIndex = hasExactTotalMessages @@ -5684,7 +6477,13 @@ async function readSessionDetail(params = {}) { } async function readSessionPlain(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : ''); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : ''))); if (!source) { return { error: 'Invalid source' }; } @@ -5695,26 +6494,57 @@ async function readSessionPlain(params = {}) { } let extracted; - try { - extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity }); - } catch (e) { - extracted = null; - } + if (source === 'gemini') { + let json; + try { + json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + json = null; + } + if (!json || typeof json !== 'object') { + return { error: 'Failed to parse session file' }; + } + const rawMessages = Array.isArray(json.messages) ? json.messages : []; + const messages = []; + for (const entry of rawMessages) { + if (!entry || typeof entry !== 'object') continue; + const role = normalizeGeminiMessageRole(entry.type); + if (!role) continue; + const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text)); + if (!text && role !== 'system') continue; + messages.push({ role, text }); + } + extracted = { + sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'), + cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '', + messages + }; + } else { + try { + extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity }); + } catch (e) { + extracted = null; + } - if (!extracted) { - return { error: 'Failed to parse session file' }; - } + if (!extracted) { + return { error: 'Failed to parse session file' }; + } - if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) { - const fallbackRecords = readJsonlRecords(filePath); - if (fallbackRecords.length === 0) { - return { error: 'Session file is empty' }; + if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) { + const fallbackRecords = readJsonlRecords(filePath); + if (fallbackRecords.length === 0) { + return { error: 'Session file is empty' }; + } + extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity }); } - extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity }); } - const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl'); - const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code'; + const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl'); + const sourceLabel = source === 'codex' + ? 'Codex' + : (source === 'claude' + ? 'Claude Code' + : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []); const text = buildSessionPlainText(messages); @@ -5729,7 +6559,13 @@ async function readSessionPlain(params = {}) { } async function exportSessionData(params = {}) { - const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : ''); + const source = params.source === 'claude' + ? 'claude' + : (params.source === 'codex' + ? 'codex' + : (params.source === 'gemini' + ? 'gemini' + : (params.source === 'codebuddy' ? 'codebuddy' : ''))); if (!source) { return { error: 'Invalid source' }; } @@ -5741,22 +6577,51 @@ async function exportSessionData(params = {}) { } let extracted; - try { - extracted = await extractMessagesFromFile(filePath, source, { maxMessages }); - } catch (e) { - extracted = null; - } + if (source === 'gemini') { + let json; + try { + json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + json = null; + } + if (!json || typeof json !== 'object') { + return { error: 'Failed to parse session file' }; + } + const rawMessages = Array.isArray(json.messages) ? json.messages : []; + const messages = []; + for (const entry of rawMessages) { + if (!entry || typeof entry !== 'object') continue; + const role = normalizeGeminiMessageRole(entry.type); + if (!role) continue; + const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text)); + if (!text && role !== 'system') continue; + messages.push({ role, text, timestamp: toIsoTime(entry.timestamp ?? entry.time ?? entry.at, '') }); + } + extracted = { + sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'), + cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '', + updatedAt: toIsoTime(json.lastUpdated ?? json.updatedAt, ''), + messages: maxMessages === Infinity ? messages : messages.slice(-maxMessages), + truncated: maxMessages !== Infinity && messages.length > maxMessages + }; + } else { + try { + extracted = await extractMessagesFromFile(filePath, source, { maxMessages }); + } catch (e) { + extracted = null; + } - if (!extracted) { - return { error: 'Failed to parse session file' }; - } + if (!extracted) { + return { error: 'Failed to parse session file' }; + } - if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) { - const fallbackRecords = readJsonlRecords(filePath); - if (fallbackRecords.length === 0) { - return { error: 'Session file is empty' }; + if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) { + const fallbackRecords = readJsonlRecords(filePath); + if (fallbackRecords.length === 0) { + return { error: 'Session file is empty' }; + } + extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages }); } - extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages }); } extracted.messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []); @@ -5768,9 +6633,13 @@ async function exportSessionData(params = {}) { } } - const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl'); + const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl'); const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_'); - const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code'; + const sourceLabel = source === 'codex' + ? 'Codex' + : (source === 'claude' + ? 'Claude Code' + : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); const truncated = !!extracted.truncated; const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages; const markdown = buildSessionMarkdown({ @@ -7537,10 +8406,11 @@ function resolveExportOutputPath(outputPath, defaultFileName) { } function printExportSessionUsage() { - console.log('\n用法: codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); + console.log('\n用法: codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); console.log('\n示例:'); console.log(' codexmate export-session --source codex --session-id 123456'); console.log(' codexmate export-session --source claude --file "~/.claude/projects/demo/session.jsonl"'); + console.log(' codexmate export-session --source codebuddy --file "~/.codebuddy/projects/demo/session.jsonl"'); console.log(' codexmate export-session --source codex --session-id 123456 --max-messages=all'); } @@ -8664,8 +9534,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'list-sessions': { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; } else { result = { sessions: await listSessionBrowse(params), @@ -8678,8 +9548,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser { const usageParams = isPlainObject(params) ? params : {}; const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; } else { result = { sessions: await listSessionUsage({ @@ -8694,8 +9564,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'list-session-paths': { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; } else { result = { paths: listSessionPaths(params) @@ -10788,7 +11658,7 @@ function buildMcpClaudeSettingsPayload() { function normalizeMcpSource(value) { const source = typeof value === 'string' ? value.trim().toLowerCase() : ''; if (!source) return ''; - if (source === 'codex' || source === 'claude' || source === 'all') { + if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'all') { return source; } return null; @@ -11101,7 +11971,7 @@ function createWorkflowToolCatalog() { handler: async (args = {}) => { const source = normalizeMcpSource(args.source); if (source === null) { - return { error: 'Invalid source. Must be codex, claude, or all' }; + return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; } return { source: source || 'all', @@ -12684,7 +13554,7 @@ function createMcpTools(options = {}) { const input = args && typeof args === 'object' ? args : {}; const source = normalizeMcpSource(input.source); if (source === null) { - return { error: 'Invalid source. Must be codex, claude, or all' }; + return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; } const normalizedInput = { ...input, @@ -13133,7 +14003,7 @@ function createMcpResources() { contents: [{ uri, mimeType: 'application/json', - text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, or all' }, null, 2) + text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }, null, 2) }] }; } @@ -13379,7 +14249,7 @@ function printMainHelp() { console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错'); console.log(' codexmate qwen [参数...] 等同于 qwen --yolo'); console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]'); - console.log(' codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); + console.log(' codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)'); console.log(' codexmate unzip [输出目录] 解压(zip-lib)'); console.log(' codexmate unzip-ext [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)'); diff --git a/cli/session-usage.js b/cli/session-usage.js index bf80536f..7cd2055b 100644 --- a/cli/session-usage.js +++ b/cli/session-usage.js @@ -7,11 +7,13 @@ async function listSessionUsageCore(params = {}, deps = {}) { listSessionBrowse, parseCodexSessionSummary, parseClaudeSessionSummary, + parseCodeBuddySessionSummary, + parseGeminiSessionSummary, MAX_SESSION_USAGE_LIST_SIZE, SESSION_BROWSE_SUMMARY_READ_BYTES } = deps; - const source = params.source === 'codex' || params.source === 'claude' + const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy' ? params.source : 'all'; const rawLimit = Number(params.limit); @@ -81,7 +83,11 @@ async function listSessionUsageCore(params = {}, deps = {}) { try { summary = normalized.source === 'claude' ? parseClaudeSessionSummary(filePath, summaryOptions) - : parseCodexSessionSummary(filePath, summaryOptions); + : (normalized.source === 'gemini' + ? parseGeminiSessionSummary(filePath, summaryOptions) + : (normalized.source === 'codebuddy' + ? parseCodeBuddySessionSummary(filePath, summaryOptions) + : parseCodexSessionSummary(filePath, summaryOptions))); } catch (_) { summary = null; } diff --git a/lib/cli-sessions.js b/lib/cli-sessions.js index 70f58a44..0b8eb358 100644 --- a/lib/cli-sessions.js +++ b/lib/cli-sessions.js @@ -164,6 +164,18 @@ function extractMessageFromRecord(record, source) { } return null; } + if (source === 'codebuddy') { + if (record.type === 'message') { + const role = normalizeRole(record.role); + const content = record.message ? record.message.content : record.content; + const text = extractMessageText(content); + if (!role || !text) { + return null; + } + return { role, text }; + } + return null; + } const role = normalizeRole(record.type); if (!role) { diff --git a/site/guide/getting-started.md b/site/guide/getting-started.md index ed7af7a6..ead9d78d 100644 --- a/site/guide/getting-started.md +++ b/site/guide/getting-started.md @@ -13,6 +13,24 @@ npm install -g codexmate ``` +### 安装官方 CLI(可选) + +Codex Mate 支持透传调用官方 CLI(例如 `codexmate codex ...`),并可在 Web UI 中浏览本地会话。建议先安装: + +```bash +# Codex CLI +npm install -g @openai/codex + +# Claude Code +npm install -g @anthropic-ai/claude-code + +# Gemini CLI +npm install -g @google/gemini-cli + +# CodeBuddy Code +npm install -g @tencent-ai/codebuddy-code +``` + ### 免安装试用 ```bash @@ -48,7 +66,7 @@ codexmate claude [model] codexmate auth codexmate workflow codexmate qwen [args...] -codexmate export-session --source --session-id +codexmate export-session --source --session-id ``` ## 校验建议 diff --git a/site/index.md b/site/index.md index 7cee55bd..f0fde079 100644 --- a/site/index.md +++ b/site/index.md @@ -34,7 +34,7 @@ Codex Mate 是一个本地优先的配置与会话管理工具,覆盖: - Codex provider/model 切换与配置写入 - Claude Code 配置方案管理(写入 `~/.claude/settings.json`) - OpenClaw JSON5 配置与 Workspace `AGENTS.md` -- Codex / Claude 本地会话浏览、导出、删除 +- Codex / Claude / Gemini CLI / CodeBuddy Code 本地会话浏览、导出、删除 ## 快速开始 @@ -86,7 +86,7 @@ codexmate run --no-browser ### 会话 -- Codex + Claude 会话统一视图 +- Codex + Claude + Gemini CLI + CodeBuddy Code 会话统一视图 - 搜索、筛选、导出、删除、批量清理 ## 测试约定 diff --git a/tests/e2e/test-sessions.js b/tests/e2e/test-sessions.js index 08e35289..1e1f5f99 100644 --- a/tests/e2e/test-sessions.js +++ b/tests/e2e/test-sessions.js @@ -3,7 +3,7 @@ const fs = require('fs'); const { assert } = require('./helpers'); module.exports = async function testSessions(ctx) { - const { api, sessionId, tmpHome, claudeSessionId, sessionPath, claudeSessionPath } = ctx; + const { api, sessionId, tmpHome, claudeSessionId, sessionPath, claudeSessionPath, geminiSessionId, geminiSessionPath } = ctx; const buildTimestamp = (baseIso, offsetSeconds) => new Date(Date.parse(baseIso) + (offsetSeconds * 1000)).toISOString(); const bestEffortApi = async (action, params) => { try { @@ -24,11 +24,17 @@ module.exports = async function testSessions(ctx) { assert(Array.isArray(apiSessionsClaude.sessions), 'api sessions(claude) missing'); assert(apiSessionsClaude.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(claude) missing claude entry'); + // ========== List Sessions Tests - Gemini ========== + const apiSessionsGemini = await api('list-sessions', { source: 'gemini', limit: 50, forceRefresh: true }); + assert(Array.isArray(apiSessionsGemini.sessions), 'api sessions(gemini) missing'); + assert(apiSessionsGemini.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(gemini) missing gemini entry'); + // ========== List Sessions Tests - All Sources ========== const apiSessionsAll = await api('list-sessions', { source: 'all', limit: 50, forceRefresh: true }); assert(Array.isArray(apiSessionsAll.sessions), 'api sessions(all) missing'); assert(apiSessionsAll.sessions.some(item => item.sessionId === sessionId), 'api sessions(all) missing codex entry'); assert(apiSessionsAll.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(all) missing claude entry'); + assert(apiSessionsAll.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(all) missing gemini entry'); // ========== List Sessions Tests - Invalid Source ========== const apiSessionsInvalid = await api('list-sessions', { source: 'invalid', limit: 50 }); @@ -75,6 +81,10 @@ module.exports = async function testSessions(ctx) { const sessionDetailClaude = await api('session-detail', { source: 'claude', sessionId: claudeSessionId }); assert(Array.isArray(sessionDetailClaude.messages), 'session-detail(claude) missing messages'); + const sessionDetailGemini = await api('session-detail', { source: 'gemini', sessionId: geminiSessionId }); + assert(Array.isArray(sessionDetailGemini.messages), 'session-detail(gemini) missing messages'); + assert(sessionDetailGemini.messages.some((m) => String(m.text || '').includes('hello from codexmate')), 'session-detail(gemini) content mismatch'); + const sessionDetailMissing = await api('session-detail', { source: 'codex', sessionId: 'missing-session' }); assert(sessionDetailMissing.error, 'session-detail should fail for missing session'); @@ -86,6 +96,9 @@ module.exports = async function testSessions(ctx) { assert(sessionPlain.text && sessionPlain.text.includes('world'), 'session-plain missing content'); assert(typeof sessionPlain.text === 'string', 'session-plain text missing'); + const sessionPlainGemini = await api('session-plain', { source: 'gemini', sessionId: geminiSessionId }); + assert(sessionPlainGemini.text && sessionPlainGemini.text.includes('hello from codexmate'), 'session-plain(gemini) missing content'); + const sessionPlainMissing = await api('session-plain', { source: 'codex', sessionId: 'missing-session' }); assert(sessionPlainMissing.error, 'session-plain should fail for missing session'); @@ -102,6 +115,9 @@ module.exports = async function testSessions(ctx) { assert(exportSessionFull.content, 'export-session(full) missing content'); assert(exportSessionFull.truncated === false, 'export-session(full) should not be truncated'); + const exportSessionGemini = await api('export-session', { source: 'gemini', sessionId: geminiSessionId, maxMessages: 100 }); + assert(exportSessionGemini.content, 'export-session(gemini) missing content'); + const exportSessionMissing = await api('export-session', { source: 'codex', sessionId: 'missing', maxMessages: 10 }); assert(exportSessionMissing.error, 'export-session should fail for missing session'); diff --git a/tests/e2e/test-setup.js b/tests/e2e/test-setup.js index 61731ad2..638b605d 100644 --- a/tests/e2e/test-setup.js +++ b/tests/e2e/test-setup.js @@ -205,6 +205,22 @@ module.exports = async function testSetup(ctx) { }; fs.writeFileSync(claudeIndexPath, JSON.stringify(claudeIndex, null, 2), 'utf-8'); + const geminiProjectHash = 'e2e-gemini-project'; + const geminiChatsDir = path.join(tmpHome, '.gemini', 'tmp', geminiProjectHash, 'chats'); + fs.mkdirSync(geminiChatsDir, { recursive: true }); + const geminiSessionId = 'gemini-e2e-session'; + const geminiSessionPath = path.join(geminiChatsDir, `${geminiSessionId}.json`); + const geminiSession = { + sessionId: geminiSessionId, + startTime: '2025-02-15T00:00:00.000Z', + lastUpdated: '2025-02-15T00:00:02.000Z', + messages: [ + { type: 'user', content: 'hello from gemini cli session', timestamp: '2025-02-15T00:00:01.000Z' }, + { type: 'gemini', content: 'hello from codexmate', timestamp: '2025-02-15T00:00:02.000Z' } + ] + }; + fs.writeFileSync(geminiSessionPath, JSON.stringify(geminiSession, null, 2), 'utf-8'); + Object.assign(ctx, { claudeModel, sessionId, @@ -216,6 +232,8 @@ module.exports = async function testSetup(ctx) { lateKeywordMessage, claudeSessionId, claudeSessionPath, + geminiSessionId, + geminiSessionPath, noModelsUrl, htmlModelsUrl, authFailUrl diff --git a/tests/unit/compact-layout-ui.test.mjs b/tests/unit/compact-layout-ui.test.mjs index c98a6d1e..26a26521 100644 --- a/tests/unit/compact-layout-ui.test.mjs +++ b/tests/unit/compact-layout-ui.test.mjs @@ -69,8 +69,8 @@ test('styles keep desktop layout wide and session history readable on large scre const titleBlock = styles.match(/\.session-item-title\s*\{[^}]*\}/); assert.ok(titleBlock, 'missing session item title style block'); - assert.match(titleBlock[0], /display:\s*-webkit-box;/); - assert.match(titleBlock[0], /-webkit-line-clamp:\s*2;/); - assert.match(titleBlock[0], /white-space:\s*normal;/); + assert.match(titleBlock[0], /white-space:\s*nowrap;/); + assert.match(titleBlock[0], /text-overflow:\s*ellipsis;/); + assert.match(titleBlock[0], /overflow:\s*hidden;/); assert.match(titleBlock[0], /max-width:\s*none;/); }); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index f5a0bfcc..f7581907 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -33,6 +33,7 @@ await import(pathToFileURL(path.join(__dirname, 'openclaw-editing.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'openclaw-persist-regression.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'agents-modal-guards.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'session-header-actions-layout.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-browser-timeline-regression.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-detail-preview-fast.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-usage.test.mjs'))); diff --git a/tests/unit/session-browser-timeline-regression.test.mjs b/tests/unit/session-browser-timeline-regression.test.mjs index bf278d5a..c1b72f51 100644 --- a/tests/unit/session-browser-timeline-regression.test.mjs +++ b/tests/unit/session-browser-timeline-regression.test.mjs @@ -5,6 +5,10 @@ import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const { withGlobalOverrides } = await import( + pathToFileURL(path.join(__dirname, 'helpers', 'web-ui-app-options.mjs')) +); + const { createSessionBrowserMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.session-browser.mjs')) ); @@ -29,6 +33,112 @@ test('loadSessionPathOptions clears visible loading state when reusing cached so assert.strictEqual(context.sessionPathOptionsLoading, false); }); +test('onSessionSourceChange prefers event target value over stale state', async () => { + const methods = createSessionBrowserMethods({ + api: async () => ({ sessions: [] }) + }); + let loadCalls = 0; + const context = { + sessionFilterSource: 'codex', + sessionPathFilter: '', + refreshSessionPathOptions() {}, + persistSessionFilterCache() {}, + loadSessions: async () => { + loadCalls += 1; + } + }; + + await methods.onSessionSourceChange.call(context, { target: { value: 'gemini' } }); + + assert.strictEqual(context.sessionFilterSource, 'gemini'); + assert.strictEqual(loadCalls, 1); +}); + +test('restoreSessionFilterCache triggers reload when url state sets source', async () => { + const methods = createSessionBrowserMethods({ + api: async () => ({ sessions: [] }) + }); + let loadCalls = 0; + const context = { + mainTab: 'sessions', + sessionFilterSource: 'codex', + sessionPathFilter: '', + sessionQuery: '', + sessionRoleFilter: 'all', + sessionTimePreset: 'all', + refreshSessionPathOptions() {}, + loadSessions: async () => { + loadCalls += 1; + } + }; + const localStorage = { + getItem() { return null; }, + setItem() {}, + removeItem() {} + }; + const window = { + location: { + href: 'http://localhost/?tab=sessions&s_source=gemini', + pathname: '/', + search: '?tab=sessions&s_source=gemini' + }, + history: { + replaceState() {} + } + }; + + await withGlobalOverrides({ window, localStorage }, async () => { + await methods.restoreSessionFilterCache.call(context); + }); + + assert.strictEqual(context.sessionFilterSource, 'gemini'); + assert.strictEqual(loadCalls, 1); +}); + +test('restoreSessionFilterCache triggers reload when stored source is not default', async () => { + const methods = createSessionBrowserMethods({ + api: async () => ({ sessions: [] }) + }); + let loadCalls = 0; + const context = { + mainTab: 'sessions', + sessionFilterSource: 'all', + sessionPathFilter: '', + sessionQuery: '', + sessionRoleFilter: 'all', + sessionTimePreset: 'all', + refreshSessionPathOptions() {}, + loadSessions: async () => { + loadCalls += 1; + } + }; + const localStorage = { + getItem(key) { + if (key === 'codexmateSessionFilterSource') return 'gemini'; + return null; + }, + setItem() {}, + removeItem() {} + }; + const window = { + location: { + href: 'http://localhost/?tab=sessions', + pathname: '/', + search: '?tab=sessions' + }, + history: { + replaceState() {} + } + }; + + await withGlobalOverrides({ window, localStorage }, async () => { + await methods.restoreSessionFilterCache.call(context); + }); + + assert.strictEqual(context.sessionFilterSource, 'gemini'); + assert.strictEqual(loadCalls, 1); +}); + test('selectSession defers detail loading until the next frame when a scheduler is available', async () => { const methods = createSessionBrowserMethods({ api: async () => ({}) diff --git a/tests/unit/session-header-actions-layout.test.mjs b/tests/unit/session-header-actions-layout.test.mjs new file mode 100644 index 00000000..002679b8 --- /dev/null +++ b/tests/unit/session-header-actions-layout.test.mjs @@ -0,0 +1,75 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.join(__dirname, '..', '..'); + +function readText(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), 'utf-8').replace(/\r\n?/g, '\n'); +} + +test('sessions header actions keep buttons inline (contract)', () => { + const html = readText('web-ui/partials/index/panel-sessions.html'); + assert( + html.includes('selector-actions sessions-header-actions'), + 'panel-sessions should mark header actions with sessions-header-actions' + ); + + const css = readText('web-ui/styles/controls-forms.css'); + assert( + /\.selector-header\s*\{[\s\S]*?flex-wrap:\s*nowrap\s*;[\s\S]*?\}/m.test(css), + 'controls-forms.css should force selector-header to nowrap' + ); + assert( + /\.sessions-header-actions\s*\{[\s\S]*?display:\s*flex\s*!important\s*;/m.test(css), + 'controls-forms.css should force sessions-header-actions display:flex' + ); + assert( + /\.sessions-header-actions\s*\{[\s\S]*?flex-wrap:\s*nowrap\s*!important\s*;/m.test(css), + 'controls-forms.css should force sessions-header-actions nowrap' + ); + assert( + /\.sessions-header-actions\s*\{[\s\S]*?align-items:\s*center\s*!important\s*;/m.test(css), + 'controls-forms.css should center sessions-header-actions items' + ); + assert( + /\.sessions-header-actions\s*>\s*\.btn-tool[\s\S]*?width:\s*auto\s*!important\s*;/m.test(css), + 'controls-forms.css should force sessions-header-actions > .btn-tool width auto' + ); + assert( + /\.sessions-header-actions\s*>\s*\.btn-tool-compact[\s\S]*?width:\s*auto\s*!important\s*;/m.test(css), + 'controls-forms.css should force sessions-header-actions > .btn-tool-compact width auto' + ); + assert( + /\.sessions-header-actions\s*>\s*\.btn-tool[\s\S]*?height:\s*28px\s*!important\s*;[\s\S]*?padding:\s*0\s+10px\s*!important\s*;[\s\S]*?font-size:\s*12px\s*!important\s*;/m.test(css), + 'controls-forms.css should normalize sessions header button metrics' + ); + assert( + /\.sessions-header-actions\s*>\s*\.btn-tool[\s\S]*?margin:\s*0\s*!important\s*;/m.test(css), + 'controls-forms.css should reset sessions header button margins' + ); + assert( + !/\.sessions-header-actions\s*\{[\s\S]*?flex-wrap:\s*wrap\b/m.test(css), + 'sessions-header-actions must not allow flex-wrap: wrap' + ); + + const stylesDir = path.join(projectRoot, 'web-ui', 'styles'); + const styleFiles = fs.readdirSync(stylesDir) + .filter((name) => name.endsWith('.css')) + .map((name) => path.join(stylesDir, name)); + const unexpectedOverrides = []; + for (const filePath of styleFiles) { + const content = fs.readFileSync(filePath, 'utf-8'); + if (content.includes('sessions-header-actions') && !filePath.endsWith(path.join('styles', 'controls-forms.css'))) { + unexpectedOverrides.push(path.basename(filePath)); + } + } + assert.deepStrictEqual( + unexpectedOverrides, + [], + `sessions-header-actions should not be overridden in other stylesheets: ${unexpectedOverrides.join(', ')}` + ); +}); diff --git a/tests/unit/session-resume-command.test.mjs b/tests/unit/session-resume-command.test.mjs new file mode 100644 index 00000000..21eb1116 --- /dev/null +++ b/tests/unit/session-resume-command.test.mjs @@ -0,0 +1,47 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createSessionActionMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.session-actions.mjs')) +); + +test('isResumeCommandAvailable supports codex and codebuddy with sessionId', () => { + const methods = createSessionActionMethods(); + assert.strictEqual(methods.isResumeCommandAvailable({ source: 'codex', sessionId: 'sess-1' }), true); + assert.strictEqual(methods.isResumeCommandAvailable({ source: 'codebuddy', sessionId: 'abc123' }), true); + assert.strictEqual(methods.isResumeCommandAvailable({ source: 'gemini', sessionId: 'gm-123' }), true); + assert.strictEqual(methods.isResumeCommandAvailable({ source: 'codebuddy', sessionId: '' }), false); + assert.strictEqual(methods.isResumeCommandAvailable({ source: 'claude', sessionId: 'sess-2' }), false); +}); + +test('buildResumeCommand generates codex resume with optional --yolo, codebuddy -r, and gemini -r', () => { + const methods = createSessionActionMethods(); + const contextBase = { + ...methods, + sessionResumeWithYolo: false + }; + + assert.strictEqual( + methods.buildResumeCommand.call(contextBase, { source: 'codex', sessionId: 'sess-1' }), + 'codex resume sess-1' + ); + + assert.strictEqual( + methods.buildResumeCommand.call({ ...contextBase, sessionResumeWithYolo: true }, { source: 'codex', sessionId: 'sess-1' }), + 'codex --yolo resume sess-1' + ); + + assert.strictEqual( + methods.buildResumeCommand.call({ ...contextBase, sessionResumeWithYolo: true }, { source: 'codebuddy', sessionId: 'abc123' }), + 'codebuddy -r abc123' + ); + + assert.strictEqual( + methods.buildResumeCommand.call({ ...contextBase, sessionResumeWithYolo: true }, { source: 'gemini', sessionId: 'gm-123' }), + 'gemini -r gm-123' + ); +}); diff --git a/tests/unit/session-usage-backend.test.mjs b/tests/unit/session-usage-backend.test.mjs index 5cabc892..a3e0e40a 100644 --- a/tests/unit/session-usage-backend.test.mjs +++ b/tests/unit/session-usage-backend.test.mjs @@ -56,6 +56,12 @@ const parseClaudeSessionSummarySrc = extractFunction(cliContent, 'parseClaudeSes function instantiateListSessionUsage(bindings = {}) { const effectiveBindings = { listSessionUsageCore: usageCore.listSessionUsageCore, + parseCodeBuddySessionSummary() { + throw new Error('should not parse codebuddy summary in this test'); + }, + parseGeminiSessionSummary() { + throw new Error('should not parse gemini summary in this test'); + }, ...(bindings || {}) }; const bindingNames = Object.keys(effectiveBindings); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index d3c9c1eb..b074824d 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -593,6 +593,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'promptComposerMissingVars', 'sessionUsageDaily', 'sessionUsageDailyTableRows', + 'usageCurrentSessionStats', 'taskOrchestrationSelectedRun', 'taskOrchestrationSelectedRunNodes', 'taskOrchestrationQueueStats', diff --git a/web-ui/app.js b/web-ui/app.js index 02dc8576..641007e6 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -178,17 +178,20 @@ document.addEventListener('DOMContentLoaded', () => { sessionPathOptionsMap: { all: [], codex: [], - claude: [] + claude: [], + gemini: [] }, sessionPathOptionsLoadedMap: { all: false, codex: false, - claude: false + claude: false, + gemini: false }, sessionPathRequestSeqMap: { all: 0, codex: 0, - claude: 0 + claude: 0, + gemini: 0 }, sessionExporting: {}, sessionCloning: {}, diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 6f939a8f..a35a275c 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -29,14 +29,14 @@ function shouldUseFastSessionBrowseLimit(options = {}) { export function isSessionQueryEnabled(source) { const normalized = normalizeSessionSource(source, ''); - return normalized === 'codex' || normalized === 'claude' || normalized === 'all'; + return normalized === 'codex' || normalized === 'claude' || normalized === 'gemini' || normalized === 'codebuddy' || normalized === 'all'; } export function normalizeSessionSource(source, fallback = 'all') { const normalized = typeof source === 'string' ? source.trim().toLowerCase() : ''; - if (normalized === 'codex' || normalized === 'claude' || normalized === 'all') { + if (normalized === 'codex' || normalized === 'claude' || normalized === 'gemini' || normalized === 'codebuddy' || normalized === 'all') { return normalized; } return fallback; diff --git a/web-ui/modules/app.computed.dashboard.mjs b/web-ui/modules/app.computed.dashboard.mjs index eea46cf0..f2d7d581 100644 --- a/web-ui/modules/app.computed.dashboard.mjs +++ b/web-ui/modules/app.computed.dashboard.mjs @@ -77,6 +77,8 @@ export function createDashboardComputed() { inspectorSessionSourceLabel() { if (this.sessionFilterSource === 'codex') return this.t('dashboard.sessionSource.codex'); if (this.sessionFilterSource === 'claude') return this.t('dashboard.sessionSource.claude'); + if (this.sessionFilterSource === 'gemini') return this.t('dashboard.sessionSource.gemini'); + if (this.sessionFilterSource === 'codebuddy') return this.t('dashboard.sessionSource.codebuddy'); return this.t('dashboard.sessionSource.all'); }, inspectorSessionPathLabel() { diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index c23e30e4..930c9fac 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -549,6 +549,23 @@ export function createSessionComputed() { ]; }, + usageCurrentSessionStats() { + const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary + ? this.sessionUsageCharts.summary + : null; + if (!summary) return null; + const t = typeof this.t === 'function' ? this.t : null; + return { + apiDurationLabel: formatUsageDuration(summary.activeDurationMs || 0, { compact: true, lang: this.lang }), + totalDurationLabel: formatUsageDuration(summary.totalDurationMs || 0, { compact: true, lang: this.lang }), + tokenLabel: formatCompactUsageSummaryNumber(summary.totalTokens || 0), + label: t ? t('usage.currentSession.title') : '当前会话', + apiDurationText: t ? t('usage.currentSession.apiDuration') : 'API时长', + totalDurationText: t ? t('usage.currentSession.totalDuration') : '总时长', + tokenText: t ? t('usage.currentSession.tokens') : 'Token' + }; + }, + sessionUsageDaily() { const baseBuckets = this.sessionUsageCharts && Array.isArray(this.sessionUsageCharts.buckets) ? this.sessionUsageCharts.buckets diff --git a/web-ui/modules/app.methods.install.mjs b/web-ui/modules/app.methods.install.mjs index f4c3aa70..4bb9f731 100644 --- a/web-ui/modules/app.methods.install.mjs +++ b/web-ui/modules/app.methods.install.mjs @@ -104,6 +104,16 @@ export function createInstallMethods() { update: '', uninstall: '' }, + codebuddy: { + install: '', + update: '', + uninstall: '' + }, + gemini: { + install: '', + update: '', + uninstall: '' + }, codex: { install: '', update: '', @@ -114,6 +124,12 @@ export function createInstallMethods() { matrix.claude.install = 'pnpm add -g @anthropic-ai/claude-code'; matrix.claude.update = 'pnpm up -g @anthropic-ai/claude-code'; matrix.claude.uninstall = 'pnpm remove -g @anthropic-ai/claude-code'; + matrix.codebuddy.install = 'pnpm add -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.update = 'pnpm up -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.uninstall = 'pnpm remove -g @tencent-ai/codebuddy-code'; + matrix.gemini.install = 'pnpm add -g @google/gemini-cli'; + matrix.gemini.update = 'pnpm up -g @google/gemini-cli'; + matrix.gemini.uninstall = 'pnpm remove -g @google/gemini-cli'; matrix.codex.install = `pnpm add -g ${codexInstallPackage}`; matrix.codex.update = `pnpm up -g ${codexPackage}`; matrix.codex.uninstall = `pnpm remove -g ${codexPackage}`; @@ -123,6 +139,12 @@ export function createInstallMethods() { matrix.claude.install = 'bun add -g @anthropic-ai/claude-code'; matrix.claude.update = 'bun update -g @anthropic-ai/claude-code'; matrix.claude.uninstall = 'bun remove -g @anthropic-ai/claude-code'; + matrix.codebuddy.install = 'bun add -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.update = 'bun update -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.uninstall = 'bun remove -g @tencent-ai/codebuddy-code'; + matrix.gemini.install = 'bun add -g @google/gemini-cli'; + matrix.gemini.update = 'bun update -g @google/gemini-cli'; + matrix.gemini.uninstall = 'bun remove -g @google/gemini-cli'; matrix.codex.install = `bun add -g ${codexInstallPackage}`; matrix.codex.update = `bun update -g ${codexPackage}`; matrix.codex.uninstall = `bun remove -g ${codexPackage}`; @@ -131,6 +153,12 @@ export function createInstallMethods() { matrix.claude.install = 'npm install -g @anthropic-ai/claude-code'; matrix.claude.update = 'npm update -g @anthropic-ai/claude-code'; matrix.claude.uninstall = 'npm uninstall -g @anthropic-ai/claude-code'; + matrix.codebuddy.install = 'npm install -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.update = 'npm update -g @tencent-ai/codebuddy-code'; + matrix.codebuddy.uninstall = 'npm uninstall -g @tencent-ai/codebuddy-code'; + matrix.gemini.install = 'npm install -g @google/gemini-cli'; + matrix.gemini.update = 'npm update -g @google/gemini-cli'; + matrix.gemini.uninstall = 'npm uninstall -g @google/gemini-cli'; matrix.codex.install = `npm install -g ${codexInstallPackage}`; matrix.codex.update = platform === 'termux' ? `npm install -g ${codexInstallPackage}` diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 8f45199c..8f571df1 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -117,7 +117,7 @@ export function createSessionActionMethods(options = {}) { if (!session) return false; const source = String(session.source || '').trim().toLowerCase(); const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : ''; - return source === 'codex' && !!sessionId; + return (source === 'codex' || source === 'codebuddy' || source === 'gemini') && !!sessionId; }, isCloneAvailable(session) { @@ -138,8 +138,15 @@ export function createSessionActionMethods(options = {}) { }, buildResumeCommand(session) { + const source = session && session.source ? String(session.source).trim().toLowerCase() : ''; const sessionId = session && session.sessionId ? String(session.sessionId).trim() : ''; const arg = this.quoteResumeArg(sessionId); + if (source === 'codebuddy') { + return `codebuddy -r ${arg}`; + } + if (source === 'gemini') { + return `gemini -r ${arg}`; + } if (this.sessionResumeWithYolo) { return `codex --yolo resume ${arg}`; } diff --git a/web-ui/modules/app.methods.session-browser.mjs b/web-ui/modules/app.methods.session-browser.mjs index 7a3ceb92..0ca7aa52 100644 --- a/web-ui/modules/app.methods.session-browser.mjs +++ b/web-ui/modules/app.methods.session-browser.mjs @@ -127,7 +127,9 @@ export function createSessionBrowserMethods(options = {}) { }, syncSessionPathOptionsForSource(source, nextOptions, mergeWithExisting = false) { - const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex'); + const targetSource = source === 'claude' + ? 'claude' + : (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex')); const current = Array.isArray(this.sessionPathOptionsMap[targetSource]) ? this.sessionPathOptionsMap[targetSource] : []; @@ -142,7 +144,9 @@ export function createSessionBrowserMethods(options = {}) { }, refreshSessionPathOptions(source) { - const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex'); + const targetSource = source === 'claude' + ? 'claude' + : (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex')); const base = Array.isArray(this.sessionPathOptionsMap[targetSource]) ? [...this.sessionPathOptionsMap[targetSource]] : []; @@ -164,7 +168,9 @@ export function createSessionBrowserMethods(options = {}) { }, async loadSessionPathOptions(options = {}) { - const source = options.source === 'claude' ? 'claude' : (options.source === 'all' ? 'all' : 'codex'); + const source = options.source === 'claude' + ? 'claude' + : (options.source === 'gemini' ? 'gemini' : (options.source === 'all' ? 'all' : 'codex')); const forceRefresh = !!options.forceRefresh; const loaded = !!this.sessionPathOptionsLoadedMap[source]; if (!forceRefresh && loaded) { @@ -252,6 +258,9 @@ export function createSessionBrowserMethods(options = {}) { const urlState = readSessionsFilterUrlState(); if (urlState) { applySessionsFilterUrlState(this, urlState); + if (this.mainTab === 'sessions' && typeof this.loadSessions === 'function') { + void this.loadSessions(); + } return; } const sourceCache = localStorage.getItem('codexmateSessionFilterSource'); @@ -266,6 +275,16 @@ export function createSessionBrowserMethods(options = {}) { this.sessionRoleFilter = normalizeSessionRoleFilter(roleCache); this.sessionTimePreset = normalizeSessionTimePreset(timeCache); this.refreshSessionPathOptions(this.sessionFilterSource); + if (this.mainTab === 'sessions' && typeof this.loadSessions === 'function') { + const shouldReload = cached.source !== 'all' + || !!cached.pathFilter + || !!(this.sessionQuery && isSessionQueryEnabled(cached.source)) + || (this.sessionRoleFilter && this.sessionRoleFilter !== 'all') + || (this.sessionTimePreset && this.sessionTimePreset !== 'all'); + if (shouldReload) { + void this.loadSessions(); + } + } }, persistSessionFilterCache() { @@ -412,7 +431,12 @@ export function createSessionBrowserMethods(options = {}) { this.persistSessionPinnedMap(); }, - async onSessionSourceChange() { + async onSessionSourceChange(event) { + const rawValue = event && event.target && typeof event.target.value === 'string' + ? event.target.value + : this.sessionFilterSource; + const cached = buildSessionFilterCacheState(rawValue, this.sessionPathFilter); + this.sessionFilterSource = cached.source; this.refreshSessionPathOptions(this.sessionFilterSource); this.persistSessionFilterCache(); syncSessionsFilterUrl(this); diff --git a/web-ui/modules/app.methods.session-trash.mjs b/web-ui/modules/app.methods.session-trash.mjs index 04cfa90a..357f8f07 100644 --- a/web-ui/modules/app.methods.session-trash.mjs +++ b/web-ui/modules/app.methods.session-trash.mjs @@ -10,13 +10,15 @@ export function createSessionTrashMethods(options = {}) { const deletedAt = typeof result.deletedAt === 'string' && result.deletedAt ? result.deletedAt : new Date().toISOString(); - const source = session && session.source === 'claude' ? 'claude' : 'codex'; + const source = session && (session.source === 'claude' || session.source === 'gemini') + ? session.source + : 'codex'; return { trashId: typeof result.trashId === 'string' ? result.trashId : '', source, sourceLabel: session && typeof session.sourceLabel === 'string' && session.sourceLabel ? session.sourceLabel - : (source === 'claude' ? 'Claude Code' : 'Codex'), + : (source === 'claude' ? 'Claude Code' : (source === 'gemini' ? 'Gemini CLI' : 'Codex')), sessionId: session && typeof session.sessionId === 'string' ? session.sessionId : '', title: session && typeof session.title === 'string' && session.title ? session.title diff --git a/web-ui/modules/i18n.dict.mjs b/web-ui/modules/i18n.dict.mjs index 7dac42fc..2bdc3479 100644 --- a/web-ui/modules/i18n.dict.mjs +++ b/web-ui/modules/i18n.dict.mjs @@ -257,6 +257,8 @@ const DICT = Object.freeze({ 'dashboard.message.none': '暂无提示', 'dashboard.sessionSource.codex': 'Codex', 'dashboard.sessionSource.claude': 'Claude Code', + 'dashboard.sessionSource.gemini': 'Gemini CLI', + 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', 'dashboard.sessionSource.all': '全部', 'dashboard.sessionPath.all': '全部路径', 'dashboard.sessionQuery.unsupported': '当前来源不支持', @@ -502,7 +504,7 @@ const DICT = Object.freeze({ // Docs panel 'docs.title': 'CLI 安装文档', - 'docs.subtitle': '查看 Claude Code / Codex CLI 命令。', + 'docs.subtitle': '查看 Claude Code / Gemini CLI / CodeBuddy Code / Codex CLI 命令。', 'docs.section.commands': '安装命令', 'docs.section.commandsNote': '命令可直接复制。', 'docs.section.faq': '常见问题', @@ -518,10 +520,10 @@ const DICT = Object.freeze({ 'docs.rule.2': '自定义镜像仅用于安装与升级。' , 'docs.tip.win.1': 'PowerShell 报权限不足(EACCES/EPERM)时,请以管理员身份执行安装命令。', - 'docs.tip.win.2': '安装后若仍提示找不到命令,重开终端并执行:where codex / where claude。', + 'docs.tip.win.2': '安装后若仍提示找不到命令,重开终端并执行:where codex / where claude / where gemini / where codebuddy。', 'docs.tip.win.3': '公司网络受限时,可先切换镜像源快捷项(npmmirror / 腾讯云 / 自定义)。', 'docs.tip.unix.1': '出现 EACCES 权限错误时,优先修复 Node 全局目录权限,不建议直接 sudo npm。', - 'docs.tip.unix.2': '安装后若命令未生效,重开终端并执行:which codex / which claude。', + 'docs.tip.unix.2': '安装后若命令未生效,重开终端并执行:which codex / which claude / which gemini / which codebuddy。', 'docs.tip.unix.3': '公司网络受限时,可先切换镜像源快捷项(npmmirror / 腾讯云 / 自定义)。' , @@ -533,10 +535,12 @@ const DICT = Object.freeze({ 'sessions.allPaths': '全部路径', 'sessions.source.codex': 'Codex', 'sessions.source.claudeCode': 'Claude Code', + 'sessions.source.gemini': 'Gemini CLI', + 'sessions.source.codebuddy': 'CodeBuddy Code', 'sessions.loadingList': '会话加载中...', 'sessions.empty': '暂无可用会话记录', 'sessions.unknownTime': '未知时间', - 'sessions.query.placeholder.enabled': '关键词检索(支持 Codex/Claude,例:claude code)', + 'sessions.query.placeholder.enabled': '关键词检索(支持 Codex/Claude/Gemini/CodeBuddy,例:claude code)', 'sessions.query.placeholder.disabled': '当前来源暂不支持关键词检索', 'sessions.pin': '置顶', 'sessions.unpin': '取消置顶', @@ -634,6 +638,10 @@ const DICT = Object.freeze({ 'usage.summary.avgMessagesPerSession': '平均每会话消息', 'usage.summary.busiestDay': '最忙日', 'usage.summary.busiestHour': '高峰时段', + 'usage.currentSession.title': '当前会话', + 'usage.currentSession.apiDuration': 'API时长', + 'usage.currentSession.totalDuration': '总时长', + 'usage.currentSession.tokens': 'Token', 'usage.range.kicker.all': '全部', 'usage.range.kicker.30d': '近 30 天', 'usage.range.kicker.7d': '近 7 天', @@ -1274,6 +1282,8 @@ const DICT = Object.freeze({ 'dashboard.message.none': 'No messages', 'dashboard.sessionSource.codex': 'Codex', 'dashboard.sessionSource.claude': 'Claude Code', + 'dashboard.sessionSource.gemini': 'Gemini CLI', + 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', 'dashboard.sessionSource.all': 'All', 'dashboard.sessionPath.all': 'All paths', 'dashboard.sessionQuery.unsupported': 'Unsupported source', @@ -1519,7 +1529,7 @@ const DICT = Object.freeze({ // Docs panel 'docs.title': 'CLI Install', - 'docs.subtitle': 'Install commands for Claude Code / Codex CLI.', + 'docs.subtitle': 'Install commands for Claude Code / Gemini CLI / CodeBuddy Code / Codex CLI.', 'docs.section.commands': 'Commands', 'docs.section.commandsNote': 'Copy and run directly.', 'docs.section.faq': 'FAQ', @@ -1535,10 +1545,10 @@ const DICT = Object.freeze({ 'docs.rule.2': 'Custom registry is used for install/update only.' , 'docs.tip.win.1': 'If PowerShell reports permission errors (EACCES/EPERM), run the install command as Administrator.', - 'docs.tip.win.2': 'If the command is still not found after install, reopen the terminal and run: where codex / where claude.', + 'docs.tip.win.2': 'If the command is still not found after install, reopen the terminal and run: where codex / where claude / where gemini / where codebuddy.', 'docs.tip.win.3': 'If your network blocks npm, try switching registry presets (npmmirror / Tencent / Custom).', 'docs.tip.unix.1': 'If you hit EACCES, fix your global Node directory permissions instead of using sudo npm.', - 'docs.tip.unix.2': 'If the command is not available after install, reopen the terminal and run: which codex / which claude.', + 'docs.tip.unix.2': 'If the command is not available after install, reopen the terminal and run: which codex / which claude / which gemini / which codebuddy.', 'docs.tip.unix.3': 'If your network blocks npm, try switching registry presets (npmmirror / Tencent / Custom).' , @@ -1550,10 +1560,12 @@ const DICT = Object.freeze({ 'sessions.allPaths': 'All paths', 'sessions.source.codex': 'Codex', 'sessions.source.claudeCode': 'Claude Code', + 'sessions.source.gemini': 'Gemini CLI', + 'sessions.source.codebuddy': 'CodeBuddy Code', 'sessions.loadingList': 'Loading sessions...', 'sessions.empty': 'No sessions found', 'sessions.unknownTime': 'unknown time', - 'sessions.query.placeholder.enabled': 'Search keywords (Codex/Claude, e.g. claude code)', + 'sessions.query.placeholder.enabled': 'Search keywords (Codex/Claude/Gemini/CodeBuddy, e.g. claude code)', 'sessions.query.placeholder.disabled': 'Keyword search is not available for this source', 'sessions.pin': 'Pin', 'sessions.unpin': 'Unpin', @@ -1651,6 +1663,10 @@ const DICT = Object.freeze({ 'usage.summary.avgMessagesPerSession': 'Avg messages/session', 'usage.summary.busiestDay': 'Busiest day', 'usage.summary.busiestHour': 'Peak hour', + 'usage.currentSession.title': 'Current session', + 'usage.currentSession.apiDuration': 'API duration', + 'usage.currentSession.totalDuration': 'Total duration', + 'usage.currentSession.tokens': 'Tokens', 'usage.range.kicker.all': 'All', 'usage.range.kicker.30d': 'Last 30 days', 'usage.range.kicker.7d': 'Last 7 days', diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index d57acf37..3471a058 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -214,7 +214,7 @@
{{ t('side.sessions.browser') }}
{{ t('side.sessions.browser.meta') }} - {{ t('sessions.sourceLabel', { value: (sessionFilterSource === 'all' ? t('sessions.source.all') : (sessionFilterSource === 'codex' ? 'Codex' : 'Claude')) }) }} + {{ t('sessions.sourceLabel', { value: (sessionFilterSource === 'all' ? t('sessions.source.all') : (sessionFilterSource === 'claude' ? 'Claude Code' : (sessionFilterSource === 'gemini' ? 'Gemini CLI' : (sessionFilterSource === 'codebuddy' ? 'CodeBuddy Code' : 'Codex')))) }) }}
@@ -35,10 +35,12 @@
- + +