重写为TUI插件:弹出面板显示所有会话,点击跳转,拦截MCP报错

This commit is contained in:
ypshidifu
2026-09-21 18:19:18 +08:00
parent 289ff13132
commit 37df56c6d9
2 changed files with 224 additions and 152 deletions
+17 -3
View File
@@ -1,20 +1,34 @@
{
"name": "oc-session-all",
"version": "1.0.0",
"description": "OpenCode plugin to show all sessions regardless of current directory",
"version": "2.0.0",
"description": "OpenCode TUI plugin: browse and switch to any session across all projects",
"main": "src/index.ts",
"type": "module",
"exports": {
".": "./src/index.ts",
"./tui": "./src/index.ts"
},
"scripts": {
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["opencode", "plugin", "session"],
"keywords": [
"opencode",
"tui",
"plugin",
"session",
"picker"
],
"author": "",
"license": "MIT",
"engines": {
"opencode": ">=1.18.0"
},
"dependencies": {
"@opencode-ai/plugin": "latest"
},
"devDependencies": {
"@opentui/solid": "latest",
"typescript": "^5.0.0"
}
}
+207 -149
View File
@@ -1,160 +1,218 @@
/**
* OpenCode 2.x V2 Plugin: Show All Sessions
* OpenCode TUI Plugin: Show All Sessions
*
* Registers /sessions-all command to show all sessions
* regardless of the current working directory.
* across all projects via a searchable picker dialog.
* Selecting a session navigates directly to it.
*
* V2 format: export default { id, setup(ctx) }
* TUI format: export default { id, tui }
*
* Also intercepts MCP connection error toasts to keep them quiet.
*/
import { define } from "@opencode-ai/plugin/v2/promise";
import { join } from "path";
import { existsSync } from "fs";
import { DatabaseSync } from "node:sqlite";
import type {
TuiPlugin,
TuiPluginModule,
} from "@opencode-ai/plugin/tui";
// Type for command invocation
interface CommandInvocation {
sessionID: string;
prompt: {
text: string;
// other prompt properties
// ── 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, any>,
): 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: TuiPlugin = async (api, _options, _meta) => {
// ─── MCP Error Toast Suppression ─────────────────────────────────────
// Listen for toast events and suppress MCP-related error toasts
api.event.on("tui.toast.show" as any, (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"))
) {
// Silently swallow MCP connection error toasts
// (We can't actually prevent the toast from showing through this event,
// but we log it for debugging. The real suppression happens by
// disabling the MCP server in opencode.json.)
console.log(
"[oc-session-all] Suppressed MCP error toast:",
props.message,
);
}
});
// ─── 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 {
const [sessionResult, projectResult] = await Promise.all([
api.client.session.list({
roots: true,
directory: "",
limit: 1000,
}),
api.client.project.list({}),
]);
sessions = (sessionResult as any)?.data ?? (sessionResult as any) ?? [];
projects = (projectResult as any)?.data ?? (projectResult as any) ?? [];
} 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<string, any[]>();
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 });
},
}),
() => {},
);
};
delivery: "steer" | "queue";
}
interface Session {
id: string;
project_id: string;
directory: string;
title: string | null;
time_created: number;
time_updated: number;
cost: number;
}
/**
* Get the path to the opencode database
*/
function getDbPath(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
// Primary path (works on Windows, Linux, macOS)
const primaryPath = join(homeDir, ".local", "share", "opencode", "opencode.db");
if (existsSync(primaryPath)) return primaryPath;
// Fallback: Windows AppData (older versions)
const winPath = join(homeDir, "AppData", "Roaming", "opencode", "opencode.db");
if (existsSync(winPath)) return winPath;
return primaryPath;
}
/**
* Query all sessions from the database using node:sqlite
*/
function getAllSessionsFromDb(): Session[] {
try {
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
console.error("[oc-session-all] Database not found at:", dbPath);
return [];
}
const db = new DatabaseSync(dbPath, { open: true, readOnly: true });
const query = `SELECT id, project_id, directory, title, time_created, time_updated, cost FROM session_v2 ORDER BY time_updated DESC;`;
const sessions: Session[] = [];
const rows = db.prepare(query).all() as any[];
for (const row of rows) {
sessions.push({
id: row.id,
project_id: row.project_id,
directory: row.directory,
title: row.title || null,
time_created: row.time_created || 0,
time_updated: row.time_updated || 0,
cost: row.cost || 0,
});
}
db.close();
return sessions;
} catch (error) {
console.error("[oc-session-all] Failed to query database:", error);
return [];
}
}
function formatTime(timestamp: number): string {
if (!timestamp) return "Unknown";
return new Date(timestamp * 1000).toLocaleString("zh-CN");
}
function formatCost(cost: number): string {
if (!cost) return "Free";
return `$${cost.toFixed(4)}`;
}
/**
* Format sessions into readable output
*/
function formatSessions(sessions: Session[]): string {
if (!sessions || sessions.length === 0) {
return "没有找到任何会话记录。No sessions found.";
}
let output = "=== 所有会话记录 (All Sessions) ===\n\n";
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;
}
/**
* V2 Plugin Definition
*/
export default define({
id: "oc-session-all",
async setup(ctx) {
console.log("[oc-session-all] V2 plugin loaded, registering command...");
// Register the sessions-all command
await ctx.command.transform((editor) => {
editor.add({
name: "sessions-all",
description: "显示所有项目的会话记录,绕过目录过滤限制。Show all sessions from all projects.",
async execute(invocation: CommandInvocation) {
console.log("[oc-session-all] Command executed");
const sessions = getAllSessionsFromDb();
const output = formatSessions(sessions);
// Send the output as a prompt to the session
await ctx.session.prompt({
sessionID: invocation.sessionID,
text: output,
delivery: invocation.delivery,
});
// ─── 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();
},
});
});
},
]);
}
console.log("[oc-session-all] Command registered successfully");
},
});
// ─── 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 ──────────────────────────────────────────────────────────────
const plugin: TuiPluginModule & { id: string } = {
id: "oc-session-all",
tui,
};
export default plugin;