重写为TUI插件:弹出面板显示所有会话,点击跳转,拦截MCP报错
This commit is contained in:
+17
-3
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user