opencode-v2-tui-closetab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 firefoxmmx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # opencode-v2-tui-closetab
2
+
3
+ > ⚠️ **学习用途 / For learning purposes** — This project is for learning OpenCode V2 plugin development only. The APIs used are beta APIs and may change between versions. Do not use in production.
4
+
5
+ A TUI plugin for OpenCode V2 that adds a `/close` command to close session tabs. **For learning / demonstration purposes only.**
6
+
7
+ ## Features
8
+
9
+ Type the slash command `/close` in the TUI. The following usages are supported:
10
+
11
+ | Usage | Description |
12
+ | --- | --- |
13
+ | `/close` | Close the currently active tab |
14
+ | `/close 3` | Close the 3rd tab |
15
+ | `/close 1,3,5-7` | Close multiple tabs by index/range (comma separated, `a-b` is a closed interval, whole input is bounds-checked) |
16
+ | `/close <keyword>` | Match by title; if exactly one match, close it directly |
17
+ | `/close <keyword>` (multiple matches) | A multi-select dialog pops up: all matches selected by default, `Space` toggles selection, the filter input disallows spaces (spaces are reserved for toggling), `Enter` confirms and closes, `Esc` cancels |
18
+
19
+ No confirmation dialogs — closing is immediate. Session content is **not** deleted; you can reopen the session later from the session list.
20
+
21
+ ## Requirements
22
+
23
+ - OpenCode V2 (docs: <https://opencode.ai/v2/docs/build/plugins/cli>)
24
+ - Dependencies: `@opentui/core`, `@opentui/solid`, `solid-js` (see `peerDependencies` in `package.json`)
25
+
26
+ ## Installation / Loading
27
+
28
+ > The plugin is not yet published to the npm registry, so install it directly from the GitHub repository.
29
+
30
+ Choose one of the following:
31
+
32
+ **1. Install from GitHub (recommended)**
33
+
34
+ Install with the CLI (it automatically adds the entry to the global `~/.config/opencode/opencode.json`):
35
+
36
+ ```sh
37
+ opencode2 plugin add github:firefoxmmx2/opencode-tui-closetab
38
+ ```
39
+
40
+ Or add the GitHub spec directly to the `plugins` array in the global `opencode.json` (or a project `opencode.json`):
41
+
42
+ ```jsonc
43
+ {
44
+ "plugins": ["github:firefoxmmx2/opencode-tui-closetab"]
45
+ }
46
+ ```
47
+
48
+ Branches, tags, and commit hashes are supported for pinning, e.g. `github:firefoxmmx2/opencode-tui-closetab#main`. Update to the latest commit with `opencode2 plugin update`.
49
+
50
+ **2. Local development (no bundling)**
51
+
52
+ Put `src/tui.tsx` into the `plugins/tui/` directory of the global config directory and it will be auto-discovered:
53
+
54
+ ```
55
+ <global-config>/plugins/tui/tui.tsx
56
+ ```
57
+
58
+ ## Project Structure
59
+
60
+ ```
61
+ opencode-v2-tui-closetab/
62
+ ├── package.json # npm package metadata (exports exposes both ./ and ./tui entry points)
63
+ ├── src/
64
+ │ ├── index.ts # Main entry: declares the plugin id and marks tui: true
65
+ │ └── tui.tsx # TUI entry: /close command implementation (incl. custom JSX multi-select dialog)
66
+ ├── README.md
67
+ └── LICENSE # MIT
68
+ ```
69
+
70
+ - `src/index.ts`: main entry, marked `tui: true`, loaded by OpenCode to associate the TUI entry.
71
+ - `src/tui.tsx`: TUI-side implementation. Key points:
72
+ - The keymap command must be registered inside the `render` callback of `ui.slot` (component context, which includes `Keymap.Provider`) — do not call `ctx.keymap.layer()` directly in `setup()`;
73
+ - The multi-select dialog is rendered via `ctx.ui.dialog.show(() => JSX)` with a custom Solid component, and keyboard handling uses `useKeyboard` from `@opentui/solid`;
74
+ - zh/en locales are auto-detected (`options.locale` or system `LANG`/`LC_ALL`).
75
+
76
+ ## Disclaimer / Learning Notice
77
+
78
+ - This plugin is **for learning purposes only** — for learning OpenCode V2 plugin development. It is not an official project.
79
+ - The plugin APIs it depends on (`@opencode-ai/plugin`, `@opentui/*`) are all **beta APIs** and may break compatibility with future versions.
80
+ - Use at your own risk. Do not use in production.
81
+
82
+ ## License
83
+
84
+ [MIT](./LICENSE)
@@ -0,0 +1,86 @@
1
+ # opencode-v2-tui-closetab
2
+
3
+ > 本文件为中文版,English version: [README.md](./README.md)
4
+
5
+ > ⚠️ **学习用途 / For learning purposes** — 本项目仅用于学习 OpenCode V2 插件开发,接口为 beta API,可能随版本变化,请勿用于生产环境。
6
+
7
+ OpenCode V2 的 TUI 插件,实现 `/close` 命令关闭会话标签页。**学习/演示用途**。
8
+
9
+ ## 功能
10
+
11
+ 在 TUI 中输入斜杠命令 `/close`,支持以下用法:
12
+
13
+ | 用法 | 说明 |
14
+ | --- | --- |
15
+ | `/close` | 关闭当前活动标签页 |
16
+ | `/close 3` | 关闭第 3 个标签页 |
17
+ | `/close 1,3,5-7` | 序号/区间多选关闭(逗号分隔,`a-b` 为闭区间,越界整体校验) |
18
+ | `/close <关键词>` | 按标题匹配;恰好一个匹配时直接关闭 |
19
+ | `/close <关键词>`(多个匹配) | 弹出多选选择器:默认全部勾选、`空格` 切换勾选、过滤框禁空格(空格保留给勾选切换)、`回车` 确认关闭、`Esc` 取消 |
20
+
21
+ 无需二次确认,关闭即关闭;会话内容不会删除,可稍后在会话列表重新打开。
22
+
23
+ ## 环境要求
24
+
25
+ - OpenCode V2(文档:<https://opencode.ai/v2/docs/build/plugins/cli>)
26
+ - 依赖:`@opentui/core`、`@opentui/solid`、`solid-js`(见 `package.json` 的 peerDependencies)
27
+
28
+ ## 安装 / 加载方式
29
+
30
+ > 插件尚未发布到 npm registry,请直接从 GitHub 仓库安装。
31
+
32
+ 二选一:
33
+
34
+ **1. 从 GitHub 安装(推荐)**
35
+
36
+ 用 CLI 安装(会自动把条目写入全局 `~/.config/opencode/opencode.json`):
37
+
38
+ ```sh
39
+ opencode2 plugin add github:firefoxmmx2/opencode-tui-closetab
40
+ ```
41
+
42
+ 或直接把 GitHub 地址写入全局 `opencode.json`(或项目 `opencode.json`)的 `plugins` 数组:
43
+
44
+ ```jsonc
45
+ {
46
+ "plugins": ["github:firefoxmmx2/opencode-tui-closetab"]
47
+ }
48
+ ```
49
+
50
+ 支持分支 / tag / commit hash 固定版本,例如 `github:firefoxmmx2/opencode-tui-closetab#main`;更新到最新提交:`opencode2 plugin update`。
51
+
52
+ **2. 本地开发(无需打包)**
53
+
54
+ 把 `src/tui.tsx` 放到全局配置目录的 `plugins/tui/` 下即可被自动发现:
55
+
56
+ ```
57
+ <global-config>/plugins/tui/tui.tsx
58
+ ```
59
+
60
+ ## 项目结构
61
+
62
+ ```
63
+ opencode-v2-tui-closetab/
64
+ ├── package.json # npm 包元数据(exports 暴露 ./ 与 ./tui 两个入口)
65
+ ├── src/
66
+ │ ├── index.ts # 主入口:声明插件 id 并标记 tui: true
67
+ │ └── tui.tsx # TUI 入口:/close 命令实现(含自定义 JSX 多选 dialog)
68
+ ├── README.md
69
+ └── LICENSE # MIT
70
+ ```
71
+
72
+ - `src/index.ts`:主入口,标记 `tui: true`,由 OpenCode 加载以关联 TUI 入口。
73
+ - `src/tui.tsx`:TUI 侧实现。核心要点:
74
+ - keymap 命令必须在 `ui.slot` 的 `render` 回调(组件上下文,含 `Keymap.Provider`)中注册,不能在 `setup()` 中直接调用 `ctx.keymap.layer()`;
75
+ - 多选选择器用 `ctx.ui.dialog.show(() => JSX)` 渲染自定义 Solid 组件,键盘处理使用 `@opentui/solid` 的 `useKeyboard`;
76
+ - 中英文自动适配(`options.locale` 或系统 `LANG`/`LC_ALL`)。
77
+
78
+ ## 免责 / 学习声明
79
+
80
+ - 本插件**仅用于学习** OpenCode V2 插件开发,非官方项目。
81
+ - 所依赖的插件 API(`@opencode-ai/plugin`、`@opentui/*`)均为 **beta API**,可能随版本变化而破坏兼容性。
82
+ - 使用风险自负,请勿用于生产环境。
83
+
84
+ ## License
85
+
86
+ [MIT](./LICENSE)
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "opencode-v2-tui-closetab",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode V2 TUI plugin: /close command to close session tabs (learning purpose).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "tui",
11
+ "closetab"
12
+ ],
13
+ "author": "firefoxmmx2",
14
+ "homepage": "https://github.com/firefoxmmx2/opencode-tui-closetab",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/firefoxmmx2/opencode-tui-closetab.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/firefoxmmx2/opencode-tui-closetab/issues"
21
+ },
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./tui": "./src/tui.tsx"
25
+ },
26
+ "files": [
27
+ "src"
28
+ ],
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@opencode-ai/plugin": "beta"
34
+ },
35
+ "peerDependencies": {
36
+ "@opentui/core": ">=0.5.8",
37
+ "@opentui/solid": ">=0.5.8",
38
+ "solid-js": ">=1.9.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.8.0",
42
+ "@opentui/core": "*",
43
+ "@opentui/solid": "*",
44
+ "solid-js": "*"
45
+ },
46
+ "publishConfig": {
47
+ "registry": "https://registry.npmjs.org",
48
+ "access": "public"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode-ai/plugin"
2
+
3
+ export default Plugin.define({
4
+ id: "opencode-v2-tui-closetab",
5
+ tui: true,
6
+ setup() {},
7
+ })
package/src/tui.tsx ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * /close —— 关闭会话标签页插件(OpenCode V2 TUI)
3
+ *
4
+ * 用法(参数直接跟在斜杠命令后面):
5
+ * /close → 关闭当前活动标签页
6
+ * /close 3 → 关闭第 3 个标签页(单个序号)
7
+ * /close 1,3,5-7 → 关闭多个标签页(序号 / 区间混合,英文逗号分隔)
8
+ * 区间 ``a-b`` 为闭区间(a <= b),序号均从 1 开始;
9
+ * 执行前会先越界校验,任一越界则提示且不会关闭任何标签页。
10
+ * /close <关键词> → 按标题匹配标签页(含一个匹配则直接关闭)
11
+ * /close <关键词>(多个匹配)→ 弹出多选选择器(默认全部勾选,可过滤、空格切换勾选,
12
+ * 回车确认关闭所有选中的标签)
13
+ *
14
+ * 无任何二次 confirm,关闭即关闭;会话内容不会删除,可稍后在会话列表重新打开。
15
+ *
16
+ * 实现要点:
17
+ * - keymap 命令必须在 ui.slot 的 render 回调(组件上下文,含 Keymap.Provider)中
18
+ * 注册,这是官方运行时支持 keymap.layer 的方式;不要在 setup() 中直接调用
19
+ * ctx.keymap.layer()(会抛 Keymap.Provider missing)。render 返回 null 不渲染,
20
+ * 用一个 registered 布尔防止重复注册。
21
+ * - 多选选择器用 ctx.ui.dialog.show(() => JSX) 渲染自定义 Solid 组件,
22
+ * 键盘用 @opentui/solid 的 useKeyboard(evt.name / evt.sequence),
23
+ * 采用"无聚焦的 filter 状态 + useKeyboard 收集字符"的方式(过滤框不收空格,
24
+ * 空格键整体用于勾选切换)。
25
+ *
26
+ * 多语言:英文环境显示英文,中文环境(options.locale 或系统 LANG/LC_ALL 以 zh 开头)
27
+ * 显示中文。
28
+ */
29
+
30
+ /** @jsxImportSource @opentui/solid */
31
+ import { Plugin } from "@opencode-ai/plugin/tui"
32
+ import { TextAttributes, RGBA, type ScrollBoxRenderable, type KeyEvent } from "@opentui/core"
33
+ import { useKeyboard } from "@opentui/solid"
34
+ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // 语言检测:显式 options.locale > 系统 LANG/LC_ALL > 默认英文
38
+ // ---------------------------------------------------------------------------
39
+ function detectLocale(options: Record<string, unknown> | undefined): "en" | "zh" {
40
+ const opt = options?.locale
41
+ if (typeof opt === "string") {
42
+ const l = opt.toLowerCase()
43
+ if (l.startsWith("zh")) return "zh"
44
+ if (l.startsWith("en")) return "en"
45
+ }
46
+ const lang = (process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "").toLowerCase()
47
+ return lang.startsWith("zh") ? "zh" : "en"
48
+ }
49
+
50
+ // ---- 纯文本文案表 ----
51
+ const STRINGS = {
52
+ cmdTitle: { en: "Close session tab", zh: "关闭会话标签页" },
53
+ cmdDescription: {
54
+ en: "Close the current tab, the N-th tab (single index or ranges like 1,3,5-7), or tabs matched by title. Usage: /close (current tab), /close 3 (N-th), /close 1,3-5 (indices/ranges), /close <keyword> (title match; opens a multi-select when several match). No confirmation is required.",
55
+ zh: "关闭当前标签页、第 N 个标签页(单个序号或区间,如 1,3,5-7),或按标题匹配的标签页。用法:/close(关闭当前)、/close 3(第 N 个)、/close 1,3-5(序号/区间)、/close <关键词>(标题匹配,多个匹配时弹出多选选择器)。无需二次确认。",
56
+ },
57
+ notEnabled: { en: "Session tabs are not enabled in this TUI.", zh: "当前 TUI 未启用会话标签页。" },
58
+ noTabs: { en: "There are no open tabs.", zh: "当前没有打开的标签页。" },
59
+ success: { en: "Session tab closed", zh: "已关闭会话标签页" },
60
+ failed: { en: "Failed to close the tab; it may no longer exist.", zh: "关闭失败,标签页可能已不存在。" },
61
+ } as const
62
+
63
+ // ---- 带参数文案表(模板函数)----
64
+ const SENDERS = {
65
+ noNth: {
66
+ en: (n: number, total: number) => `No tab #${n} (currently ${total} tabs).`,
67
+ zh: (n: number, total: number) => `没有第 ${n} 个标签页(当前共 ${total} 个)。`,
68
+ },
69
+ noTitleMatch: {
70
+ en: (q: string) => `No tab whose title contains "${q}".`,
71
+ zh: (q: string) => `没有标题包含「${q}」的标签页。`,
72
+ },
73
+ invalidRange: {
74
+ en: (v: string) => `Invalid range "${v}". Use single indices or a-b ranges, e.g. 1,3,5-7 (a must be <= b).`,
75
+ zh: (v: string) => `无效的数字范围「${v}」。请使用单个序号或 a-b 区间(a <= b),如 1,3,5-7。`,
76
+ },
77
+ closedMany: {
78
+ en: (n: number) => `Closed ${n} session tabs`,
79
+ zh: (n: number) => `已关闭 ${n} 个会话标签页`,
80
+ },
81
+ failedMany: {
82
+ en: (n: number) => `${n} session tabs could not be closed; they may no longer exist.`,
83
+ zh: (n: number) => `${n} 个标签页关闭失败,可能已不存在。`,
84
+ },
85
+ closedPartly: {
86
+ en: (ok: number, fail: number) => `Closed ${ok} session tabs; ${fail} could not be closed (they may no longer exist).`,
87
+ zh: (ok: number, fail: number) => `已关闭 ${ok} 个标签页,其中 ${fail} 个关闭失败(可能已不存在)。`,
88
+ },
89
+ mcNone: {
90
+ en: () => "No session tabs selected.",
91
+ zh: () => "未选择任何标签页。",
92
+ },
93
+ mcTitle: {
94
+ en: (n: number) => `Close ${n} session tab${n === 1 ? "" : "s"}`,
95
+ zh: (n: number) => `关闭 ${n} 个标签页`,
96
+ },
97
+ mcFilter: { en: () => "Filter", zh: () => "过滤" },
98
+ mcFilterPlaceholder: { en: () => "type to filter by title…", zh: () => "输入关键词过滤(空格用于勾选)…" },
99
+ mcNoResults: { en: () => "No matching session tabs", zh: () => "没有匹配的标签页" },
100
+ mcMove: { en: () => "↑/↓ move", zh: () => "↑/↓ 选择" },
101
+ mcToggle: { en: () => "space toggle", zh: () => "空格 勾选" },
102
+ mcConfirm: { en: () => "enter close", zh: () => "回车 关闭" },
103
+ mcCancel: { en: () => "esc cancel", zh: () => "Esc 取消" },
104
+ } as const
105
+
106
+ type Locale = "en" | "zh"
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 数字 / 数字范围解析
110
+ // ---------------------------------------------------------------------------
111
+ // 匹配语法:单个整数 或 a-b 区间(a、b 为 1~4 位整数,a<=b),段之间英文逗号分隔;
112
+ // 段内/段间允许出现空白。示例:3 / 1,2,3 / 1-3 / 1, 3-5, 7
113
+ const RANGE_RE = /^\d{1,4}(\s*-\s*\d{1,4})?(\s*,\s*\d{1,4}(\s*-\s*\d{1,4})?)*$/
114
+
115
+ function isRangeSyntax(input: string): boolean {
116
+ return RANGE_RE.test(input)
117
+ }
118
+
119
+ /**
120
+ * 解析数字范围字符串,展开为一组 1-based 索引(去重、保持出现顺序)。
121
+ * 语法不合法(如 a>b 的区间)返回 null。
122
+ */
123
+ function parseRange(input: string): number[] | null {
124
+ if (!isRangeSyntax(input)) return null
125
+ const seen = new Set<number>()
126
+ const out: number[] = []
127
+ const add = (n: number) => {
128
+ if (!seen.has(n)) {
129
+ seen.add(n)
130
+ out.push(n)
131
+ }
132
+ }
133
+ for (const rawSeg of input.split(",")) {
134
+ const seg = rawSeg.trim()
135
+ const dash = /^(\d{1,4})\s*-\s*(\d{1,4})$/.exec(seg)
136
+ if (dash) {
137
+ const a = Number(dash[1])
138
+ const b = Number(dash[2])
139
+ if (a > b) return null
140
+ for (let i = a; i <= b; i++) add(i)
141
+ } else {
142
+ add(Number(seg))
143
+ }
144
+ }
145
+ return out
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // 多选选择器(自定义 dialog 内容)
150
+ // ---------------------------------------------------------------------------
151
+ type TabOption = { sessionID: string; title?: string }
152
+
153
+ type MultiSelectStrings = {
154
+ title: (n: number) => string
155
+ filter: string
156
+ placeholder: string
157
+ noResults: string
158
+ move: string
159
+ toggle: string
160
+ confirm: string
161
+ cancel: string
162
+ }
163
+
164
+ const FG_MUTED = RGBA.fromInts(140, 140, 140, 255)
165
+ const FG_DIM = RGBA.fromInts(108, 108, 108, 255)
166
+ const BG_CURSOR = RGBA.fromInts(255, 255, 255, 26)
167
+
168
+ function MultiSelect(props: {
169
+ options: TabOption[]
170
+ strings: MultiSelectStrings
171
+ onConfirm: (sessionIDs: string[]) => void
172
+ onCancel: () => void
173
+ }) {
174
+ const [filter, setFilter] = createSignal("")
175
+ const [cursor, setCursor] = createSignal(0)
176
+ const [selectedIds, setSelectedIds] = createSignal<Set<string>>(
177
+ new Set(props.options.map((o) => o.sessionID)),
178
+ )
179
+
180
+ const filtered = createMemo(() => {
181
+ const q = filter().toLowerCase()
182
+ if (!q) return props.options
183
+ return props.options.filter((o) => (o.title ?? o.sessionID).toLowerCase().includes(q))
184
+ })
185
+
186
+ // 过滤后光标越界归一(clamp)
187
+ const effectiveCursor = createMemo(() => {
188
+ const len = filtered().length
189
+ if (len === 0) return 0
190
+ return Math.min(cursor(), len - 1)
191
+ })
192
+
193
+ let scroll: ScrollBoxRenderable | undefined
194
+
195
+ const toggle = (sessionID: string) => {
196
+ setSelectedIds((prev) => {
197
+ const next = new Set(prev)
198
+ if (next.has(sessionID)) next.delete(sessionID)
199
+ else next.add(sessionID)
200
+ return next
201
+ })
202
+ }
203
+
204
+ const toggleAtCursor = () => {
205
+ const list = filtered()
206
+ const idx = effectiveCursor()
207
+ const opt = list[idx]
208
+ if (opt) toggle(opt.sessionID)
209
+ }
210
+
211
+ const scrollToCursor = () => {
212
+ if (!scroll || scroll.isDestroyed) return
213
+ const list = filtered()
214
+ const opt = list[effectiveCursor()]
215
+ if (!opt) return
216
+ const target = scroll.getChildren().find((child) => child.id === `mc:${opt.sessionID}`)
217
+ if (!target) return
218
+ const y = target.y - scroll.y
219
+ if (y < 0) scroll.scrollBy(y)
220
+ else if (y >= scroll.height && scroll.height > 0) scroll.scrollBy(y - scroll.height + 1)
221
+ }
222
+
223
+ createEffect(() => {
224
+ // filter / 列表变化时:光标越界归一,并滚动跟随
225
+ filtered()
226
+ const len = filtered().length
227
+ if (len === 0) {
228
+ setCursor(0)
229
+ return
230
+ }
231
+ if (cursor() >= len) setCursor(len - 1)
232
+ scrollToCursor()
233
+ })
234
+
235
+ useKeyboard((evt: KeyEvent) => {
236
+ if (evt.name === "up") {
237
+ evt.preventDefault()
238
+ evt.stopPropagation()
239
+ const len = filtered().length
240
+ if (len > 0) setCursor((c) => ((Math.min(c, len - 1) - 1 + len) % len))
241
+ return
242
+ }
243
+ if (evt.name === "down") {
244
+ evt.preventDefault()
245
+ evt.stopPropagation()
246
+ const len = filtered().length
247
+ if (len > 0) setCursor((c) => ((Math.min(c, len - 1) + 1) % len))
248
+ return
249
+ }
250
+ if (evt.name === "return" || evt.name === "enter") {
251
+ evt.preventDefault()
252
+ evt.stopPropagation()
253
+ const ids = props.options
254
+ .filter((o) => selectedIds().has(o.sessionID))
255
+ .map((o) => o.sessionID)
256
+ props.onConfirm(ids)
257
+ return
258
+ }
259
+ if (evt.name === "escape") {
260
+ evt.preventDefault()
261
+ evt.stopPropagation()
262
+ props.onCancel()
263
+ return
264
+ }
265
+ if (evt.name === "space") {
266
+ // 空格:切换当前光标项选中/取消(不输入到过滤框)
267
+ evt.preventDefault()
268
+ evt.stopPropagation()
269
+ toggleAtCursor()
270
+ return
271
+ }
272
+ if (evt.name === "backspace") {
273
+ evt.preventDefault()
274
+ evt.stopPropagation()
275
+ setFilter((f) => f.slice(0, -1))
276
+ return
277
+ }
278
+ // 其余(字母/数字/标点等)输入到过滤框;忽略带修饰键的组合与空白/控制字符
279
+ if (!evt.ctrl && !evt.meta && !evt.alt && !evt.option) {
280
+ const ch = evt.sequence
281
+ if (ch && ch.length === 1 && !/\s/.test(ch) && !/[\x00-\x1f\x7f]/.test(ch)) {
282
+ evt.preventDefault()
283
+ evt.stopPropagation()
284
+ setFilter((f) => f + ch)
285
+ }
286
+ }
287
+ })
288
+
289
+ const str = props.strings
290
+
291
+ return (
292
+ <box flexDirection="column" paddingLeft={2} paddingRight={2} gap={1}>
293
+ <box flexDirection="row" justifyContent="space-between">
294
+ <text attributes={TextAttributes.BOLD}>{str.title(selectedIds().size)}</text>
295
+ <text fg={FG_MUTED} onMouseUp={() => props.onCancel()}>
296
+ esc
297
+ </text>
298
+ </box>
299
+
300
+ <box flexDirection="row" gap={1}>
301
+ <text fg={FG_MUTED}>{str.filter}:</text>
302
+ <Show
303
+ when={filter()}
304
+ fallback={<text fg={FG_DIM}>{str.placeholder}</text>}
305
+ >
306
+ <text>{filter()}</text>
307
+ </Show>
308
+ </box>
309
+
310
+ <Show
311
+ when={filtered().length > 0}
312
+ fallback={
313
+ <box paddingTop={1} paddingLeft={1}>
314
+ <text fg={FG_MUTED}>{str.noResults}</text>
315
+ </box>
316
+ }
317
+ >
318
+ <scrollbox
319
+ flexDirection="column"
320
+ maxHeight={8}
321
+ scrollbarOptions={{ visible: false }}
322
+ ref={(r: ScrollBoxRenderable | undefined) => (scroll = r)}
323
+ >
324
+ <For each={filtered()}>
325
+ {(opt) => {
326
+ const isCursor = createMemo(() => filtered()[effectiveCursor()]?.sessionID === opt.sessionID)
327
+ const isOn = createMemo(() => selectedIds().has(opt.sessionID))
328
+ return (
329
+ <box
330
+ id={`mc:${opt.sessionID}`}
331
+ flexDirection="row"
332
+ gap={1}
333
+ paddingLeft={1}
334
+ paddingRight={1}
335
+ backgroundColor={isCursor() ? BG_CURSOR : undefined}
336
+ onMouseUp={() => toggle(opt.sessionID)}
337
+ onMouseOver={() => {
338
+ const idx = filtered().findIndex((o) => o.sessionID === opt.sessionID)
339
+ if (idx !== -1) setCursor(idx)
340
+ }}
341
+ >
342
+ <text fg={isOn() ? undefined : FG_MUTED} attributes={isCursor() ? TextAttributes.BOLD : undefined}>
343
+ [{isOn() ? "x" : " "}]
344
+ </text>
345
+ <text attributes={isCursor() ? TextAttributes.BOLD : undefined} wrapMode="none" overflow="hidden">
346
+ {opt.title ?? opt.sessionID}
347
+ </text>
348
+ </box>
349
+ )
350
+ }}
351
+ </For>
352
+ </scrollbox>
353
+ </Show>
354
+
355
+ <box flexDirection="row" gap={2} paddingTop={1} flexShrink={0}>
356
+ <text fg={FG_DIM}>{str.move}</text>
357
+ <text fg={FG_DIM}>{str.toggle}</text>
358
+ <text fg={FG_DIM}>{str.confirm}</text>
359
+ <text fg={FG_DIM}>{str.cancel}</text>
360
+ </box>
361
+ </box>
362
+ )
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // 插件定义
367
+ // ---------------------------------------------------------------------------
368
+ export default Plugin.define({
369
+ id: "tab-close",
370
+ setup(ctx) {
371
+ const { ui } = ctx
372
+ const tabs = ui.tabs
373
+
374
+ // setup 在服务端执行,这里一次性算好 locale;render 回调通过闭包共用
375
+ const locale = detectLocale(ctx.options)
376
+ const t = (key: keyof typeof STRINGS) => STRINGS[key][locale]
377
+ const s = (key: keyof typeof SENDERS, ...args: unknown[]) =>
378
+ (SENDERS[key][locale] as (...a: unknown[]) => string)(...args)
379
+
380
+ // render 运行在组件上下文(有 Keymap.Provider),在此注册 keymap 命令
381
+ let registered = false
382
+ ui.slot({
383
+ append: "app",
384
+ render: () => {
385
+ if (registered) return null
386
+ registered = true
387
+ ctx.keymap.layer(() => ({
388
+ mode: "global",
389
+ priority: 10,
390
+ commands: [
391
+ {
392
+ id: "tab.close",
393
+ title: t("cmdTitle"),
394
+ description: t("cmdDescription"),
395
+ group: "Session",
396
+ palette: true,
397
+ slash: { name: "close", arguments: true },
398
+ enabled: () => tabs.enabled(),
399
+ run: (input) => {
400
+ void start(input)
401
+ },
402
+ },
403
+ ],
404
+ }))
405
+ return null
406
+ },
407
+ })
408
+
409
+ // ---- 入口 ----
410
+ async function start(arg: string | undefined) {
411
+ if (!tabs.enabled()) {
412
+ ui.toast.show({ variant: "warning", title: t("cmdTitle"), message: t("notEnabled") })
413
+ return
414
+ }
415
+ const list = tabs.list()
416
+ if (!list.length) {
417
+ ui.toast.show({ variant: "warning", title: t("cmdTitle"), message: t("noTabs") })
418
+ return
419
+ }
420
+
421
+ const value = (arg ?? "").trim()
422
+
423
+ // 1) 无参数 → 关闭当前活动标签页(省略 sessionID,无需 confirm)
424
+ if (!value) {
425
+ reportClose(closeCurrent())
426
+ return
427
+ }
428
+
429
+ // 2) 数字 / 数字范围(含单个数字)
430
+ if (isRangeSyntax(value)) {
431
+ const indices = parseRange(value)
432
+ if (!indices) {
433
+ await ui.dialog.alert({ title: t("cmdTitle"), message: s("invalidRange", value) })
434
+ return
435
+ }
436
+ // 先整体越界校验,任一越界则提示且不关闭任何标签页
437
+ for (const n of indices) {
438
+ if (n < 1 || n > list.length) {
439
+ await ui.dialog.alert({ title: t("cmdTitle"), message: s("noNth", n, list.length) })
440
+ return
441
+ }
442
+ }
443
+ const targets = indices.map((n) => list[n - 1])
444
+ reportClose(closeMany(targets.map((tab) => tab.sessionID)))
445
+ return
446
+ }
447
+
448
+ // 3) 标题匹配
449
+ const q = value.toLowerCase()
450
+ const matches = list.filter((tab) => (tab.title ?? tab.sessionID).toLowerCase().includes(q))
451
+ if (!matches.length) {
452
+ await ui.dialog.alert({ title: t("cmdTitle"), message: s("noTitleMatch", value) })
453
+ return
454
+ }
455
+ if (matches.length === 1) {
456
+ reportClose(closeMany([matches[0].sessionID]))
457
+ return
458
+ }
459
+
460
+ // 4) 多个匹配 → 多选选择器(默认全部选中,无二次确认)
461
+ const selected = await openMultiSelect(
462
+ matches.map((tab) => ({ sessionID: tab.sessionID, title: tab.title })),
463
+ {
464
+ title: (n: number) => s("mcTitle", n),
465
+ filter: s("mcFilter"),
466
+ placeholder: s("mcFilterPlaceholder"),
467
+ noResults: s("mcNoResults"),
468
+ move: s("mcMove"),
469
+ toggle: s("mcToggle"),
470
+ confirm: s("mcConfirm"),
471
+ cancel: s("mcCancel"),
472
+ },
473
+ )
474
+ if (!selected.length) {
475
+ ui.toast.show({ variant: "warning", title: t("cmdTitle"), message: s("mcNone") })
476
+ return
477
+ }
478
+ reportClose(closeMany(selected))
479
+ }
480
+
481
+ // ---- 弹出多选选择器,返回选中的 sessionID 数组(取消返回空数组)----
482
+ function openMultiSelect(options: TabOption[], strings: MultiSelectStrings): Promise<string[]> {
483
+ return new Promise((resolve) => {
484
+ let settled = false
485
+ const finish = (ids: string[]) => {
486
+ if (settled) return
487
+ settled = true
488
+ ui.dialog.clear()
489
+ resolve(ids)
490
+ }
491
+ ui.dialog.show(
492
+ () => (
493
+ <MultiSelect
494
+ options={options}
495
+ strings={strings}
496
+ onConfirm={(ids) => finish(ids)}
497
+ onCancel={() => finish([])}
498
+ />
499
+ ),
500
+ // 兜底:dialog 被其它途径关闭(如宿主 clear)时也 settle
501
+ () => finish([]),
502
+ )
503
+ })
504
+ }
505
+
506
+ // ---- 关闭当前活动标签页(tabs.close() 省略 sessionID)----
507
+ function closeCurrent(): { ok: number; fail: number } {
508
+ return tabs.close() ? { ok: 1, fail: 0 } : { ok: 0, fail: 1 }
509
+ }
510
+
511
+ // ---- 逐个关闭并汇总结果 ----
512
+ function closeMany(sessionIDs: string[]): { ok: number; fail: number } {
513
+ let ok = 0
514
+ let fail = 0
515
+ for (const id of sessionIDs) {
516
+ if (tabs.close(id)) ok++
517
+ else fail++
518
+ }
519
+ return { ok, fail }
520
+ }
521
+
522
+ function reportClose(result: { ok: number; fail: number }) {
523
+ const total = result.ok + result.fail
524
+ if (total === 0) {
525
+ // 没有任何可关闭的项(例如当前无活动标签页且未传参)
526
+ ui.toast.show({ variant: "info", message: t("noTabs") })
527
+ return
528
+ }
529
+ if (result.fail === 0) {
530
+ if (total === 1) {
531
+ ui.toast.show({ variant: "success", message: t("success") })
532
+ } else {
533
+ ui.toast.show({ variant: "success", message: s("closedMany", total) })
534
+ }
535
+ return
536
+ }
537
+ if (result.ok === 0) {
538
+ if (total === 1) {
539
+ ui.toast.show({ variant: "error", message: t("failed") })
540
+ } else {
541
+ ui.toast.show({ variant: "error", message: s("failedMany", total) })
542
+ }
543
+ return
544
+ }
545
+ ui.toast.show({ variant: "error", message: s("closedPartly", result.ok, result.fail) })
546
+ }
547
+ },
548
+ })