Files
oc_session/tui-plugins/oc-session-all.js
T

234 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plugin } from "@opencode/plugin/tui"
import { appendFileSync, mkdirSync, existsSync } from "fs"
import { join } from "path"
import { homedir } from "os"
// ── 日志(跨平台:Windows C:\Users\... / WSL /root)────────────────────
const LOG_DIR = join(homedir(), ".config", "opencode")
const LOG_FILE = join(LOG_DIR, "oc-session-all.log")
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true })
}
function log(msg) {
try {
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch (e) {}
console.log(msg)
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── Helpers(移植自 v1)────────────────────────────────────────────────
function formatDateTime(ms) {
const d = new Date(ms)
const pad = (v) => String(v).padStart(2, "0")
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function projectDisplayName(session, projectMap) {
const project = session.projectID ? projectMap.get(session.projectID) : undefined
if (project?.name?.trim()) return project.name.trim()
const worktree = project?.worktree?.replace(/\/+$/, "").split(/[\\/]/).pop()
if (worktree) return worktree
const dir = session.directory?.replace(/\/+$/, "").split(/[\\/]/).pop()
if (dir) return dir
const locDir = session.location?.directory?.replace(/\/+$/, "").split(/[\\/]/).pop()
if (locDir) return locDir
return session.projectID ? session.projectID.slice(0, 8) : "unknown"
}
export default Plugin.define({
id: "oc-session-all",
async setup(context) {
log("[oc-session-all] v5 loaded — official slot({append}) + layer(()=>({})) pattern")
// ── MCP error toast 拦截(monkey-patch toast.show,无未验证 API)──
try {
const toast = context.ui.toast
const orig = toast.show.bind(toast)
toast.show = (opts) => {
try {
const msg = String(opts?.message ?? "").toLowerCase()
if (
opts?.variant === "error" &&
(msg.includes("mcp") ||
msg.includes("failed to connect") ||
msg.includes("connection refused"))
) {
return orig({
...opts,
variant: "info",
message: "MCP server offline (auto-reconnect when available)",
duration: 2000,
})
}
} catch (e) {
log("[oc-session-all] toast interceptor check ERROR: " + e.message)
}
return orig(opts)
}
log("[oc-session-all] MCP toast interceptor installed")
} catch (e) {
log("[oc-session-all] toast interceptor install ERROR: " + e.message)
}
// ── Session 选择器(移植自 v1,改用 v2 dialog.select/router API)──
const openPicker = async () => {
log("[oc-session-all] openPicker called")
try {
let sessions = []
let projects = []
// 数据源 1:client SDK
try {
const r = await context.client.session.list({ limit: 1000, order: "desc" })
sessions = (r && r.data) || r || []
log("[oc-session-all] client.session.list -> " + (Array.isArray(sessions) ? sessions.length + " items" : "non-array"))
} catch (e) {
log("[oc-session-all] client.session.list ERROR: " + e.message)
}
// 数据源 2:data store 回退(内置 /sessions 也用它)
if (!Array.isArray(sessions) || sessions.length === 0) {
try {
sessions = context.data.session.list() || []
log("[oc-session-all] data.session.list fallback -> " + sessions.length + " items")
} catch (e) {
log("[oc-session-all] data.session.list ERROR: " + e.message)
}
}
try {
const r = await context.client.project.list({})
projects = (r && r.data) || r || []
} catch (e) {
log("[oc-session-all] client.project.list ERROR: " + e.message)
}
if (!Array.isArray(sessions) || sessions.length === 0) {
context.ui.toast.show({ message: "No sessions found", variant: "warning" })
return
}
const projectMap = new Map(
(Array.isArray(projects) ? projects : []).map((p) => [p.id, p]),
)
// 过滤归档与子代理会话
const visible = sessions.filter((s) => !s.time?.archived && !s.parentID)
if (visible.length === 0) {
context.ui.toast.show({ message: "No sessions found", variant: "warning" })
return
}
// 按项目分组
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)
}
// 组内按更新时间倒序
for (const list of grouped.values()) {
list.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
}
// 项目名排序
const orderedProjects = [...grouped.keys()].sort((a, b) => a.localeCompare(b))
const options = orderedProjects.flatMap((project) =>
grouped.get(project).map((session) => ({
title: `${project} ${
session.time?.updated ? formatDateTime(session.time.updated) : "unknown"
} │ ${session.title || "Untitled"}`,
value: session.id,
category: project,
})),
)
log("[oc-session-all] picker options built: " + options.length)
// v2 dialog.select 返回 Promise<value|undefined>
const selected = await context.ui.dialog.select({
title: "All Sessions",
placeholder: "Search sessions across all projects…",
options,
})
log("[oc-session-all] dialog.select resolved: " + String(selected))
if (selected) {
context.ui.router.navigate({ type: "session", sessionID: selected })
}
} catch (e) {
log("[oc-session-all] openPicker ERROR: " + (e.stack || e.message))
try {
context.ui.toast.show({
message: "Failed to load sessions: " + e.message,
variant: "error",
})
} catch (e2) {}
}
}
// ── 命令注册:官方 opencode.btw 插件同款写法 ──────────────────────
try {
context.ui.slot({
append: "app",
render() {
log("[oc-session-all] slot render called — registering keymap layer")
try {
return context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "sessions.all",
title: "All Sessions",
description: "Browse and switch to any session across all projects",
group: "Session",
palette: true,
bind: "ctrl+o",
slash: {
name: "sessions-all",
aliases: ["resume-all", "continue-all"],
},
run() {
void openPicker()
},
},
],
}))
} catch (e) {
log("[oc-session-all] layer inside render ERROR: " + e.message)
return null
}
},
})
log("[oc-session-all] ui.slot({append:'app'}) registered OK")
} catch (e) {
log("[oc-session-all] ui.slot ERROR: " + e.message)
}
// ── 验证命令注册结果(写日志,不依赖 toast)────────────────────────
await delay(4000)
try {
const cmds = context.keymap.commands()
const s = JSON.stringify(cmds)
const ok = s.includes("sessions.all") || s.includes("sessions-all")
log("[oc-session-all] VERIFY commands() contains sessions-all: " + ok)
if (!ok) log("[oc-session-all] VERIFY commands() dump: " + s.substring(0, 600))
} catch (e) {
log("[oc-session-all] VERIFY commands() ERROR: " + e.message)
}
// toast 延时显示(避免被其他插件覆盖)
await delay(3000)
try {
context.ui.toast.show({
message: "oc-session-all v5 loaded — /sessions-all or Ctrl+O",
variant: "info",
duration: 20000,
})
} catch (e) {
log("[oc-session-all] toast.show ERROR: " + e.message)
}
},
})