From 258a40b0d001ab2311b49162da8710cd6bf4cd3c Mon Sep 17 00:00:00 2001 From: ypshidifu Date: Mon, 21 Sep 2026 19:41:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86:=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E7=9A=84=20src/=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=85=A5=E5=8F=A3=E4=B8=BA=20tui-pl?= =?UTF-8?q?ugins/=EF=BC=8C=E6=9B=B4=E6=96=B0=20README=20=E5=92=8C=20packag?= =?UTF-8?q?e.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 108 ++++++++------------------ package.json | 6 +- src/index.js | 134 --------------------------------- src/index.ts | 208 --------------------------------------------------- 5 files changed, 33 insertions(+), 424 deletions(-) delete mode 100644 src/index.js delete mode 100644 src/index.ts diff --git a/.gitignore b/.gitignore index f5f2ba6..1c338d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ *.js +src/ diff --git a/README.md b/README.md index 18d38f7..c453175 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,48 @@ # oc-session-all -OpenCode 插件:在任意目录下显示所有会话记录,绕过目录过滤限制。 +OpenCode TUI 插件:弹出全项目会话选择器,跨所有项目浏览和切换会话。 ## 功能 -- 拦截 `/session` 命令,显示所有项目的会话记录,而不仅仅是当前目录的 -- 提供 `/sessions-all` 命令作为替代方案 -- 按更新时间倒序显示会话列表 +- `Ctrl+o` 或 `/sessions-all` 打开可搜索的会话选择面板 +- 按项目分组、按更新时间倒序排列 +- 过滤掉已归档和子代理会话 +- 拦截 MCP 连接错误 toast,保持界面整洁 ## 安装 -### 方法 1:全局安装(推荐) +在 `~/.config/opencode/tui.json` 中添加: -1. 将 `src/index.ts` 复制到全局插件目录: - ```bash - # Windows - mkdir -p %USERPROFILE%\.config\opencode\plugins - copy src\index.ts %USERPROFILE%\.config\opencode\plugins\oc-session-all.ts - - # Linux/macOS - mkdir -p ~/.config/opencode/plugins - cp src/index.ts ~/.config/opencode/plugins/oc-session-all.ts - ``` - -2. 重启 OpenCode - -### 方法 2:项目级安装 - -1. 将 `src/index.ts` 复制到项目目录: - ```bash - mkdir -p .opencode/plugins - cp src/index.ts .opencode/plugins/oc-session-all.ts - ``` - -2. 重启 OpenCode - -## 使用方法 - -### 方法 1:拦截 /session 命令 - -插件会自动拦截 `/session` 命令,显示所有会话记录。 - -### 方法 2:使用新命令 - -运行 `/sessions-all` 命令来显示所有会话记录。 - -## 输出示例 - -``` -=== All Sessions (across all projects) === - -ID: ses_12af0b69cffeAMR3VcfAdp94Hi -Title: OpenCode WSL 迁移 -Directory: C:/Users/ypshidifu -Created: 2026/9/20 10:00:00 -Updated: 2026/9/20 15:30:00 ---- -ID: ses_12aeb915effez1zEjjIqf6yl1c -Title: Session 2 -Directory: D:/桌面/源码/mc_plugn/windowmsgbot -Created: 2026/9/20 09:00:00 -Updated: 2026/9/20 14:00:00 ---- - -Total: 68 sessions +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": [ + "D:/桌面/源码/oc_session/tui-plugins/oc-session-all.ts" + ] +} ``` -## 工作原理 +重启 OpenCode 即可生效。 -插件通过两种方式获取所有会话: +## 快捷键 -1. **OpenCode Client API**:尝试使用 OpenCode 的客户端 API 获取会话列表 -2. **数据库直接查询**:如果 API 不可用,直接查询 SQLite 数据库 +| 快捷键 | 命令 | 说明 | +|--------|------|------| +| `Ctrl+o` | — | 打开会话选择器 | +| `/sessions-all` | `resume-all`, `continue-all` | 斜杠命令 | -## 配置 +## 项目结构 -插件无需额外配置,安装后即可使用。 - -## 故障排除 - -如果插件不工作: - -1. 确保插件文件已正确放置在插件目录 -2. 重启 OpenCode -3. 检查控制台日志是否有错误信息 -4. 确保 SQLite3 命令行工具可用(用于数据库查询回退方案) - -## 依赖 - -- OpenCode 1.18.11 或更新版本 -- SQLite3 命令行工具(可选,用于数据库查询回退方案) +``` +oc_session/ +├── tui-plugins/ +│ └── oc-session-all.ts ← 插件源码(OC 加载入口) +├── package.json +├── node_modules/ +├── .gitignore +└── README.md +``` ## 许可证 -MIT \ No newline at end of file +MIT diff --git a/package.json b/package.json index 8156c10..f9f757e 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,12 @@ "name": "oc-session-all", "version": "2.0.0", "description": "OpenCode TUI plugin: browse and switch to any session across all projects", - "main": "src/index.ts", + "main": "tui-plugins/oc-session-all.ts", "type": "module", "exports": { - ".": "./src/index.ts", - "./tui": "./src/index.ts" + ".": "./tui-plugins/oc-session-all.ts" }, "scripts": { - "build": "tsc", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 52ff805..0000000 --- a/src/index.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * OpenCode Plugin: Show All Sessions (JavaScript version) - * - * This plugin provides /sessions-all command to show all sessions - * regardless of the current working directory. - */ - -const { exec } = require("child_process"); -const { promisify } = require("util"); -const { join } = require("path"); -const { existsSync } = require("fs"); - -const execAsync = promisify(exec); - -/** - * Get the path to the opencode database - */ -function getDbPath() { - const homeDir = process.env.HOME || process.env.USERPROFILE || ""; - - // Windows and Linux/macOS path (opencode uses ~/.local/share/opencode/opencode.db on all platforms) - const dbPath = join(homeDir, ".local", "share", "opencode", "opencode.db"); - if (existsSync(dbPath)) return dbPath; - - // Fallback to Windows AppData (older versions) - const winPath = join(homeDir, "AppData", "Roaming", "opencode", "opencode.db"); - if (existsSync(winPath)) return winPath; - - // Final fallback - return dbPath; -} - -/** - * Query all sessions from the database - */ -async function getAllSessionsFromDb() { - try { - const dbPath = getDbPath(); - - const query = `SELECT id, project_id, directory, title, time_created, time_updated, cost FROM session_v2 ORDER BY time_updated DESC;`; - - const { stdout } = await execAsync(`sqlite3 "${dbPath}" "${query}"`); - - if (!stdout.trim()) { - return []; - } - - const sessions = []; - const lines = stdout.trim().split("\n"); - - for (const line of lines) { - const parts = line.split("|"); - if (parts.length >= 7) { - sessions.push({ - id: parts[0], - project_id: parts[1], - directory: parts[2], - title: parts[3] || null, - time_created: parseInt(parts[4]) || 0, - time_updated: parseInt(parts[5]) || 0, - cost: parseFloat(parts[6]) || 0, - }); - } - } - - return sessions; - } catch (error) { - console.error("[oc-session-all] Failed to query database:", error); - return []; - } -} - -/** - * Format a timestamp to local string - */ -function formatTime(timestamp) { - if (!timestamp) return "Unknown"; - return new Date(timestamp * 1000).toLocaleString("zh-CN"); -} - -/** - * Format cost to display string - */ -function formatCost(cost) { - if (!cost) return "Free"; - return `$${cost.toFixed(4)}`; -} - -/** - * OpenCode Plugin - */ -const OcSessionAll = async ({ project, client, $, directory, worktree }) => { - console.log("[oc-session-all] Plugin loaded"); - - return { - tool: { - "sessions-all": { - description: "显示所有项目的会话记录,绕过目录过滤限制。Show all sessions from all projects.", - args: {}, - async execute(args, context) { - const sessions = await getAllSessionsFromDb(); - - if (!sessions || sessions.length === 0) { - return "没有找到任何会话记录。No sessions found."; - } - - let output = "=== 所有会话记录 (All Sessions) ===\n\n"; - - // Display sessions - let index = 1; - for (const session of sessions) { - const created = formatTime(session.time_created); - const updated = formatTime(session.time_updated); - const cost = formatCost(session.cost); - - output += `${index}. ${session.title || "Untitled"}\n`; - output += ` ID: ${session.id}\n`; - output += ` 目录: ${session.directory}\n`; - output += ` 创建: ${created} | 更新: ${updated}\n`; - output += ` 费用: ${cost}\n\n`; - - index++; - } - - output += `共 ${sessions.length} 个会话`; - - return output; - } - } - } - }; -}; - -module.exports = OcSessionAll; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index e3d85a9..0000000 --- a/src/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * OpenCode TUI Plugin: Show All Sessions - * - * Registers /sessions-all command to show all sessions - * across all projects via a searchable picker dialog. - * Selecting a session navigates directly to it. - * - * TUI format: export default { id, tui } - * - * Also intercepts MCP connection error toasts to keep them quiet. - */ - -// ── Helpers ──────────────────────────────────────────────────────────── - -function formatDateTime(ms: number): string { - const d = new Date(ms); - const pad = (v: number) => String(v).padStart(2, "0"); - const month = pad(d.getMonth() + 1); - const day = pad(d.getDate()); - const hour = pad(d.getHours()); - const min = pad(d.getMinutes()); - return `${d.getFullYear()}/${month}/${day} ${hour}:${min}`; -} - -function projectDisplayName( - session: any, - projectMap: Map, -): string { - const project = session.projectID - ? projectMap.get(session.projectID) - : undefined; - // Try project name first - if (project?.name?.trim()) return project.name.trim(); - // Try worktree basename - const worktree = project?.worktree - ?.replace(/\/+$/, "") - .split(/[\\/]/) - .pop(); - if (worktree) return worktree; - // Try directory basename - const dir = session.directory - ?.replace(/\/+$/, "") - .split(/[\\/]/) - .pop(); - if (dir) return dir; - // Fallback to truncated project ID - return session.projectID ? session.projectID.slice(0, 8) : "unknown"; -} - -// ── Plugin ───────────────────────────────────────────────────────────── - -const tui = async (api: any) => { - // ─── MCP Error Toast Suppression ───────────────────────────────────── - // Listen for toast events and suppress MCP-related error toasts. - // This keeps the UI clean when baota-mcp server is offline. - // When the server comes back online, tools auto-reconnect. - api.event.on("tui.toast.show", (event: any) => { - const props = event.properties; - if ( - props.variant === "error" && - typeof props.message === "string" && - (props.message.toLowerCase().includes("mcp") || - props.message.toLowerCase().includes("mcp server") || - props.message.toLowerCase().includes("mcp error") || - props.message.toLowerCase().includes("failed to connect") || - props.message.toLowerCase().includes("connection refused")) - ) { - // Replace the error message with a silent info toast - props.variant = "info"; - props.message = "MCP server offline (auto-reconnect when available)"; - props.duration = 2000; - } - }); - - // ─── Session Picker Dialog ──────────────────────────────────────────── - const openPicker = async () => { - // Only work in base mode (not in a permission dialog etc.) - if (api.mode.current() !== "base") return; - - let sessions: any[] = []; - let projects: any[] = []; - - try { - // Use experimental.session.list to get all sessions across projects - const [sessionResult, projectResult] = await Promise.all([ - api.client.experimental.session.list({ - limit: 1000, - roots: true, - directory: "", - }), - api.client.project.list({}), - ]); - - sessions = sessionResult?.data ?? sessionResult ?? []; - projects = projectResult?.data ?? projectResult ?? []; - } catch (err) { - api.ui.toast({ - message: "Failed to load sessions", - variant: "error", - }); - return; - } - - if (!Array.isArray(sessions) || !Array.isArray(projects)) return; - - // Build project lookup map - const projectMap = new Map(projects.map((p: any) => [p.id, p])); - - // Filter: hide archived and subagent sessions - const visible = sessions.filter( - (s: any) => !s.time?.archived && !s.parentID, - ); - - if (visible.length === 0) { - api.ui.toast({ - message: "No sessions found", - variant: "warning", - }); - return; - } - - // Group by project - const grouped = new Map(); - for (const session of visible) { - const name = projectDisplayName(session, projectMap); - const list = grouped.get(name) ?? []; - list.push(session); - grouped.set(name, list); - } - - // Sort sessions within each project by update time (newest first) - for (const list of grouped.values()) { - list.sort( - (a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0), - ); - } - - // Sort projects alphabetically - const orderedProjects = [...grouped.keys()].sort((a, b) => - a.localeCompare(b), - ); - - // Build dialog options - const options = orderedProjects.flatMap((project) => - grouped.get(project)!.map((session: any) => { - const updated = session.time?.updated - ? formatDateTime(session.time.updated) - : "unknown"; - const title = session.title || "Untitled"; - return { - title: `${project} ${updated} \u2502 ${title}`, - value: session.id, - category: project, - }; - }), - ); - - // Show the dialog - api.ui.dialog.setSize("xlarge"); - api.ui.dialog.replace( - () => - api.ui.DialogSelect({ - title: "All Sessions", - placeholder: "Search sessions across all projects\u2026", - options, - onSelect: (option: any) => { - api.ui.dialog.clear(); - api.route.navigate("session", { sessionID: option.value }); - }, - }), - () => {}, - ); - }; - - // ─── Register Command ───────────────────────────────────────────────── - // Register /sessions-all as a slash command - if (api.command) { - api.command.register(() => [ - { - title: "All Sessions", - value: "sessions.all", - description: "Browse and switch to any session across all projects", - category: "Session Picker", - slash: { name: "sessions-all", aliases: ["resume-all", "continue-all"] }, - onSelect: () => { - void openPicker(); - }, - }, - ]); - } - - // ─── Keybinding ─────────────────────────────────────────────────────── - // Ctrl+o: dedicated fallback keybinding to open the picker - api.keymap.registerLayer({ - bindings: [ - { - key: "ctrl+o", - desc: "All sessions picker", - preventDefault: true, - cmd: () => void openPicker(), - }, - ], - }); -}; - -// ── Export ────────────────────────────────────────────────────────────── - -export default { id: "oc-session-all", tui };