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 +21 -0
- package/README.md +84 -0
- package/README.zh-CN.md +86 -0
- package/package.json +50 -0
- package/src/index.ts +7 -0
- package/src/tui.tsx +548 -0
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)
|
package/README.zh-CN.md
ADDED
|
@@ -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
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
|
+
})
|