v5: 按官方 opencode.btw 模板注册命令 — ui.slot({append:app,render}) + keymap.layer(()=>({mode,commands})),恢复全功能(sessions-all slash命令+ctrl+o+选择器+MCP toast拦截)
This commit is contained in:
+186
-90
@@ -2,132 +2,228 @@ import { Plugin } from "@opencode/plugin/tui"
|
||||
import { appendFileSync, mkdirSync, existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
// 日志文件路径
|
||||
// ── 日志 ──────────────────────────────────────────────────────────────
|
||||
const LOG_DIR = "C:\\Users\\ypshidifu\\.config\\opencode"
|
||||
const LOG_FILE = join(LOG_DIR, "oc-session-all.log")
|
||||
|
||||
// 确保日志目录存在
|
||||
if (!existsSync(LOG_DIR)) {
|
||||
mkdirSync(LOG_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const line = `[${timestamp}] ${msg}\n`
|
||||
try {
|
||||
appendFileSync(LOG_FILE, line)
|
||||
} catch (e) {
|
||||
// 忽略写入错误
|
||||
}
|
||||
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
|
||||
} catch (e) {}
|
||||
console.log(msg)
|
||||
}
|
||||
|
||||
// 延时函数
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, 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] v4 Plugin loaded! Calling ui.slot(component) directly...")
|
||||
log("[oc-session-all] v5 loaded — official slot({append}) + layer(()=>({})) pattern")
|
||||
|
||||
// 延时 5 秒,避免 toast 被覆盖(别人插件也会弹)
|
||||
await delay(5000)
|
||||
|
||||
const report = []
|
||||
|
||||
// ── 探测 ui.slot 函数本身的元信息 ──────────────────────────────────────
|
||||
if (context.ui && typeof context.ui.slot === "function") {
|
||||
report.push("ui.slot fn name=" + context.ui.slot.name + " arity=" + context.ui.slot.length)
|
||||
log("[oc-session-all] ui.slot is a function, name=" + context.ui.slot.name + ", arity=" + context.ui.slot.length)
|
||||
} else {
|
||||
report.push("ui.slot NOT a function")
|
||||
log("[oc-session-all] ui.slot is NOT a function, type=" + typeof (context.ui && context.ui.slot))
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
// ── 直接调用 ui.slot(component) 注册组件 ──────────────────────────────────────
|
||||
let slotReturn = "not called"
|
||||
try {
|
||||
// 组件在 SolidJS 渲染上下文内执行,此时 Keymap.Provider 应该可用
|
||||
const ProbeComponent = () => {
|
||||
log("[oc-session-all] ProbeComponent RENDERED inside slot!")
|
||||
// 在组件渲染阶段尝试 keymap.layer(此时应在 Provider 内)
|
||||
// ── 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 {
|
||||
if (context.keymap && typeof context.keymap.layer === "function") {
|
||||
const layerResult = context.keymap.layer({
|
||||
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: "Show all sessions",
|
||||
id: "sessions.all",
|
||||
title: "All Sessions",
|
||||
description: "Browse and switch to any session across all projects",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
log("[oc-session-all] /sessions-all command RUN!")
|
||||
palette: true,
|
||||
bind: "ctrl+o",
|
||||
slash: {
|
||||
name: "sessions-all",
|
||||
aliases: ["resume-all", "continue-all"],
|
||||
},
|
||||
run() {
|
||||
void openPicker()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{
|
||||
key: "ctrl+o",
|
||||
command: "sessions-all",
|
||||
},
|
||||
],
|
||||
})
|
||||
log("[oc-session-all] keymap.layer() inside component: SUCCESS, result type=" + typeof layerResult)
|
||||
report.push("layer-in-component: SUCCESS")
|
||||
} else {
|
||||
log("[oc-session-all] keymap.layer not available inside component")
|
||||
report.push("layer-in-component: keymap.layer missing")
|
||||
}))
|
||||
} catch (e) {
|
||||
log("[oc-session-all] layer inside render ERROR: " + e.message)
|
||||
return null
|
||||
}
|
||||
} catch (e) {
|
||||
log("[oc-session-all] keymap.layer() inside component ERROR: " + e.message)
|
||||
report.push("layer-in-component ERROR: " + e.message)
|
||||
}
|
||||
// 组件不渲染任何可见内容
|
||||
return null
|
||||
}
|
||||
|
||||
// 直接调用 slot 函数注册组件
|
||||
const ret = context.ui.slot(ProbeComponent)
|
||||
slotReturn = typeof ret
|
||||
report.push("ui.slot(component) returned type=" + typeof ret)
|
||||
log("[oc-session-all] ui.slot(component) called, returned type=" + typeof ret + ", value=" + String(ret))
|
||||
},
|
||||
})
|
||||
log("[oc-session-all] ui.slot({append:'app'}) registered OK")
|
||||
} catch (e) {
|
||||
report.push("ui.slot(component) ERROR: " + e.message)
|
||||
log("[oc-session-all] ui.slot(component) ERROR: " + e.message)
|
||||
log("[oc-session-all] ui.slot ERROR: " + e.message)
|
||||
}
|
||||
|
||||
// 等待组件渲染
|
||||
await delay(3000)
|
||||
|
||||
// ── 验证命令是否注册成功 ──────────────────────────────────────
|
||||
// ── 验证命令注册结果(写日志,不依赖 toast)────────────────────────
|
||||
await delay(4000)
|
||||
try {
|
||||
if (context.keymap && typeof context.keymap.commands === "function") {
|
||||
const cmds = context.keymap.commands()
|
||||
const cmdStr = JSON.stringify(cmds)
|
||||
log("[oc-session-all] commands() after slot: " + cmdStr.substring(0, 800))
|
||||
if (cmdStr.includes("sessions-all")) {
|
||||
report.push("sessions-all REGISTERED!")
|
||||
log("[oc-session-all] === sessions-all command REGISTERED SUCCESSFULLY ===")
|
||||
} else {
|
||||
report.push("sessions-all NOT in commands()")
|
||||
}
|
||||
}
|
||||
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] commands() check ERROR: " + e.message)
|
||||
log("[oc-session-all] VERIFY commands() ERROR: " + e.message)
|
||||
}
|
||||
|
||||
// 最终报告
|
||||
const finalReport = report.join(" | ")
|
||||
log("[oc-session-all] SLOT PROBE: " + finalReport)
|
||||
|
||||
// 延时后显示 toast
|
||||
await delay(2000)
|
||||
// toast 延时显示(避免被其他插件覆盖)
|
||||
await delay(3000)
|
||||
try {
|
||||
context.ui.toast.show({
|
||||
message: "Slot probe: " + finalReport,
|
||||
message: "oc-session-all v5 loaded — /sessions-all or Ctrl+O",
|
||||
variant: "info",
|
||||
duration: 25000,
|
||||
duration: 20000,
|
||||
})
|
||||
} catch (e) {
|
||||
log("[oc-session-all] toast.show ERROR: " + e.message)
|
||||
|
||||
Reference in New Issue
Block a user