opencode-fold-tools 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 +22 -0
- package/README.md +109 -0
- package/README.zh-CN.md +109 -0
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/index.js +273 -0
- package/package.json +25 -0
- package/scripts/configure.mjs +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ningzimu
|
|
4
|
+
Copyright (c) 2026 tanman24
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# opencode-fold-tools
|
|
2
|
+
|
|
3
|
+
[简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Compact, expandable tool output for the OpenCode TUI. Each tool call keeps its own input summary in conversation order. Click the summary to reveal its output underneath; only the arrow changes on the input line.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
▸ Read src/example.ts
|
|
9
|
+
▸ Shell npm test
|
|
10
|
+
|
|
11
|
+
▾ Shell npm test
|
|
12
|
+
│ ✓ tests passed
|
|
13
|
+
│ …output continues here…
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The expanded output has a theme-colored background and a left border. Collapsing it removes the output panel and its background. Consecutive reads and searches are not grouped.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Click a tool summary to expand or collapse its output. Long text output scrolls inside its panel.
|
|
21
|
+
- Keep native colored diffs and line numbers for Edit, Write, and Apply Patch where available.
|
|
22
|
+
- Press **Ctrl+O** to expand or collapse all matched tool rows.
|
|
23
|
+
- Open **Ctrl+P → 所有工具详情** (All tool details) to inspect tool records, including running calls that have no matching inline row. This fallback shows both input and output.
|
|
24
|
+
- Keep an **打开子任务 →** (Open subtask) link for Task calls with a child session.
|
|
25
|
+
|
|
26
|
+
Command labels and status messages are currently in Chinese. Tool names and output retain their original text.
|
|
27
|
+
|
|
28
|
+
## Compatibility
|
|
29
|
+
|
|
30
|
+
Tested with **OpenCode 1.18.27** in **Otty**. The plugin uses OpenCode's internal render tree; other versions and terminals may behave differently. It does not patch the OpenCode executable.
|
|
31
|
+
|
|
32
|
+
Do not enable this plugin alongside `opencode-fold-diffs` or another plugin that rewrites the same tool rows. Remove the old plugin entry from your TUI configuration before installation.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Install the npm package globally through OpenCode:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
opencode plugin opencode-fold-tools -g
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart OpenCode to load the plugin. This method does not require a separate Node.js installation or a Git checkout.
|
|
43
|
+
|
|
44
|
+
## Update
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
opencode plugin opencode-fold-tools@latest -g --force
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Restart OpenCode after updating.
|
|
51
|
+
|
|
52
|
+
## Install from source
|
|
53
|
+
|
|
54
|
+
For a local checkout, you need **Node.js 22 or later**, **Git**, and OpenCode. Use either the npm package or a source checkout, not both.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
git clone https://github.com/ningzimu/opencode-fold-tools.git
|
|
58
|
+
cd opencode-fold-tools
|
|
59
|
+
npm ci --ignore-scripts
|
|
60
|
+
npm run install:plugin
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The installer adds this checkout's `index.js` as an absolute file URL to your global `tui.json` or `tui.jsonc`, preserving other settings. Keep the checkout in place: OpenCode loads the plugin from it. Restart OpenCode after installation.
|
|
64
|
+
|
|
65
|
+
For a specific configuration file:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm run install:plugin -- --config /path/to/tui.jsonc
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
To update a source installation, run these commands inside your existing checkout, then restart OpenCode:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
git pull --ff-only
|
|
75
|
+
npm ci --ignore-scripts
|
|
76
|
+
npm run install:plugin
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If you installed into a custom configuration, pass the same `-- --config /path/to/tui.jsonc` argument again.
|
|
80
|
+
|
|
81
|
+
## Verify and troubleshoot
|
|
82
|
+
|
|
83
|
+
To run the automated checks from a source checkout (Node.js 22 or later):
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm test
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
After restarting OpenCode, open a session containing tool calls. Confirm that **Ctrl+P** lists **所有工具详情**, then click a Read or Shell summary. Its output should appear below, on a background, while the summary stays unchanged except for its arrow. Click again to hide the panel, and try **Ctrl+O** for all matched rows.
|
|
90
|
+
|
|
91
|
+
If keyboard toggling works but clicking does not, clear any text selection and check that the terminal forwards mouse events. In Otty, enable **Settings → Advanced → All Settings → Allow Mouse Capture**.
|
|
92
|
+
|
|
93
|
+
If a row cannot be matched to OpenCode's current layout, use **所有工具详情** as a fallback. The plugin can show only the output OpenCode retains; it cannot recover compacted or truncated history. Automated tests do not establish compatibility with every OpenCode version or terminal.
|
|
94
|
+
|
|
95
|
+
## Uninstall
|
|
96
|
+
|
|
97
|
+
For an npm installation, remove the `opencode-fold-tools` entry (including any version suffix) from the `plugin` array in your global `tui.json` or `tui.jsonc`, then restart OpenCode.
|
|
98
|
+
|
|
99
|
+
For a source installation, run this from the checkout:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npm run uninstall:plugin
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For a custom configuration, append `-- --config /path/to/tui.jsonc`. Uninstall removes only this plugin's configuration entry. Restart OpenCode, then delete the checkout if you no longer need it.
|
|
106
|
+
|
|
107
|
+
## License and credits
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE). Inspired by [opencode-fold-diffs](https://github.com/tannerbruhn/opencode-fold-diffs) by Tanner Bruhn.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# opencode-fold-tools
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
为 OpenCode TUI 提供紧凑、可展开的工具输出。每次工具调用按原顺序保留一行输入摘要,点击后在下方展开输出;输入行只改变箭头。
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
▸ Read src/example.ts
|
|
9
|
+
▸ Shell npm test
|
|
10
|
+
|
|
11
|
+
▾ Shell npm test
|
|
12
|
+
│ ✓ tests passed
|
|
13
|
+
│ …后续输出…
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
展开的输出区域使用主题背景色和左侧边线,收起后输出及背景一起消失。连续的读取和搜索不会合并分组。
|
|
17
|
+
|
|
18
|
+
## 功能
|
|
19
|
+
|
|
20
|
+
- 点击工具摘要展开或收起输出,长文本输出可在面板内滚动。
|
|
21
|
+
- Edit、Write、Apply Patch 在可用时保留原生 diff 高亮和行号。
|
|
22
|
+
- **Ctrl+O**:统一展开或收起所有已匹配的工具行。
|
|
23
|
+
- **Ctrl+P → 所有工具详情**:查看工具记录,包括尚未匹配到行内展示的运行中调用。此备用入口同时展示输入和输出。
|
|
24
|
+
- Task 存在子会话时保留 **打开子任务 →** 入口。
|
|
25
|
+
|
|
26
|
+
当前命令名称和状态提示使用中文,工具名及输出保留原文。
|
|
27
|
+
|
|
28
|
+
## 兼容性
|
|
29
|
+
|
|
30
|
+
已在 **OpenCode 1.18.27 + Otty** 中验证。插件依赖 OpenCode 内部渲染树,其他版本或终端的表现可能不同;不会修改 OpenCode 可执行文件。
|
|
31
|
+
|
|
32
|
+
请勿同时启用 `opencode-fold-diffs` 或其他改写相同工具行的插件。安装前先从 TUI 配置中移除旧插件条目。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
通过 OpenCode 全局安装 npm 包:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
opencode plugin opencode-fold-tools -g
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
重启 OpenCode 后加载插件。此方式无需额外安装 Node.js 或克隆 Git 仓库。
|
|
43
|
+
|
|
44
|
+
## 更新
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
opencode plugin opencode-fold-tools@latest -g --force
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
更新后重启 OpenCode。
|
|
51
|
+
|
|
52
|
+
## 从源码安装
|
|
53
|
+
|
|
54
|
+
本地源码安装需要 **Node.js 22 或更高版本**、**Git** 和 OpenCode。npm 包与源码安装请选择一种,不要同时启用。
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
git clone https://github.com/ningzimu/opencode-fold-tools.git
|
|
58
|
+
cd opencode-fold-tools
|
|
59
|
+
npm ci --ignore-scripts
|
|
60
|
+
npm run install:plugin
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
安装器将此仓库中的 `index.js` 以绝对文件 URL 加入全局 `tui.json` 或 `tui.jsonc`,保留其他设置。**请保留仓库目录**,OpenCode 会从这里加载插件。安装后重启 OpenCode。
|
|
64
|
+
|
|
65
|
+
指定配置文件:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm run install:plugin -- --config /path/to/tui.jsonc
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
更新源码安装时,在原仓库目录中运行,然后重启 OpenCode:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
git pull --ff-only
|
|
75
|
+
npm ci --ignore-scripts
|
|
76
|
+
npm run install:plugin
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
如果安装时指定了配置文件,更新时同样添加 `-- --config /path/to/tui.jsonc`。
|
|
80
|
+
|
|
81
|
+
## 验证与排障
|
|
82
|
+
|
|
83
|
+
在源码仓库中运行自动化检查(需要 Node.js 22 或更高版本):
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm test
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
重启 OpenCode 后,打开包含工具调用的会话。在 **Ctrl+P** 中确认存在 **所有工具详情**,然后点击 Read 或 Shell 摘要:下方应出现带背景的输出,输入摘要保持不变,仅箭头变化。再次点击应隐藏输出面板;使用 **Ctrl+O** 检查所有已匹配行的统一切换。
|
|
90
|
+
|
|
91
|
+
如果快捷键有效但点击无效,先清除文本选区,并检查终端是否向应用转发鼠标事件。Otty 中需开启 **Settings → Advanced → All Settings → Allow Mouse Capture**。
|
|
92
|
+
|
|
93
|
+
无法匹配当前 OpenCode 布局的工具行,可从 **所有工具详情** 查看。插件只能展示 OpenCode 仍保留的输出,无法恢复已压缩或截断的历史内容。自动化测试不代表所有 OpenCode 版本或终端都已兼容。
|
|
94
|
+
|
|
95
|
+
## 卸载
|
|
96
|
+
|
|
97
|
+
npm 安装:从全局 `tui.json` 或 `tui.jsonc` 的 `plugin` 数组中移除 `opencode-fold-tools` 条目(包括可能存在的版本后缀),然后重启 OpenCode。
|
|
98
|
+
|
|
99
|
+
源码安装:在仓库目录中运行:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npm run uninstall:plugin
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
使用自定义配置时,添加 `-- --config /path/to/tui.jsonc`。卸载仅移除此插件的配置条目。重启 OpenCode 后,即可按需删除仓库目录。
|
|
106
|
+
|
|
107
|
+
## 许可与致谢
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE) 开源许可。受 Tanner Bruhn 的 [opencode-fold-diffs](https://github.com/tannerbruhn/opencode-fold-diffs) 启发。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Attribution
|
|
2
|
+
|
|
3
|
+
This project grew from a local adaptation of [opencode-fold-diffs](https://github.com/tannerbruhn/opencode-fold-diffs) by Tanner Bruhn, licensed under MIT (copyright 2026 tanman24). Its approach to discovering tool renderables and preserving native file diffs informed this implementation. The original copyright notice is retained in LICENSE.
|
|
4
|
+
|
|
5
|
+
OpenCode and OpenTUI are separate projects. This is an independent community plugin, not an official OpenCode product.
|
|
6
|
+
|
|
7
|
+
The installer uses [jsonc-parser](https://github.com/microsoft/node-jsonc-parser), licensed under MIT, to preserve JSONC settings and comments. The plugin itself has no runtime package imports beyond Node built-ins.
|
package/index.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
|
|
4
|
+
const children = node => node?.getChildren?.() ?? []
|
|
5
|
+
const plain = node => typeof node?.plainText === 'string' ? node.plainText : ''
|
|
6
|
+
const clean = text => text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
|
|
7
|
+
const json = value => JSON.stringify(value ?? {}, null, 2)
|
|
8
|
+
const running = part => ['pending', 'running'].includes(part.state.status)
|
|
9
|
+
const scalar = (input, omit = []) => {
|
|
10
|
+
const values = Object.entries(input).filter(([key, value]) => !omit.includes(key) && ['string', 'number', 'boolean'].includes(typeof value))
|
|
11
|
+
return values.length ? `[${values.map(([key, value]) => `${key}=${value}`).join(', ')}]` : ''
|
|
12
|
+
}
|
|
13
|
+
function fileLabel(value, directory) {
|
|
14
|
+
if (typeof value !== 'string') return ''
|
|
15
|
+
const absolute = path.resolve(directory, value)
|
|
16
|
+
const relative = path.relative(directory, absolute)
|
|
17
|
+
if (!relative) return '.'
|
|
18
|
+
if (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) return relative
|
|
19
|
+
const home = os.homedir()
|
|
20
|
+
return absolute.startsWith(home + path.sep) ? '~' + path.sep + absolute.slice(home.length + 1) : absolute
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function details(part, includeInput = false) {
|
|
24
|
+
const s = part.state
|
|
25
|
+
let output = s.status === 'error' ? s.error : s.output ?? s.metadata?.output
|
|
26
|
+
if (s.time?.compacted) output = '[历史输出已被 OpenCode 压缩,无法恢复原文]'
|
|
27
|
+
else if (output === undefined) output = running(part) ? '[正在运行,尚无输出]' : '[无文本输出]'
|
|
28
|
+
const input = part.tool === 'bash' ? `$ ${s.input?.command ?? ''}` : `输入\n${json(s.input)}`
|
|
29
|
+
return clean(`${includeInput ? input + '\n\n' : ''}${s.status === 'error' ? '错误\n' : ''}${typeof output === 'string' ? output : json(output)}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function summary(part, directory) {
|
|
33
|
+
const input = part.state.input ?? {}
|
|
34
|
+
const file = fileLabel(input.filePath, directory)
|
|
35
|
+
switch (part.tool) {
|
|
36
|
+
case 'bash': return `Shell ${input.command ?? ''}`
|
|
37
|
+
case 'read': return `Read ${file}`
|
|
38
|
+
case 'glob': return `Glob ${input.pattern ?? ''}`
|
|
39
|
+
case 'grep': return `Grep ${input.pattern ?? ''}`
|
|
40
|
+
case 'edit': return `Edit ${file}`
|
|
41
|
+
case 'write': return `Write ${file}`
|
|
42
|
+
case 'task': return `Task ${input.description ?? ''}`
|
|
43
|
+
case 'webfetch': return `WebFetch ${input.url ?? ''}`
|
|
44
|
+
case 'websearch': return `WebSearch ${input.query ?? ''}`
|
|
45
|
+
case 'skill': return `Skill ${input.name ?? ''}`
|
|
46
|
+
case 'apply_patch': return `Patch ${(part.state.metadata?.files ?? []).map(file => file.relativePath).join(', ')}`
|
|
47
|
+
case 'todowrite': return `Todos ${input.todos?.length ?? 0} 项`
|
|
48
|
+
case 'question': return `Questions ${input.questions?.length ?? 0} 项`
|
|
49
|
+
default: return `${part.tool} ${Object.values(input).find(v => typeof v === 'string') ?? ''}`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function groups(messages, getParts) {
|
|
54
|
+
return messages.filter(message => message.role === 'assistant').flatMap(message =>
|
|
55
|
+
getParts(message.id).filter(part => part.type === 'tool').map(part => ({
|
|
56
|
+
id: part.id, parentID: message.parentID, parts: [part],
|
|
57
|
+
})))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function groupTitle(group, directory) {
|
|
61
|
+
return summary(group.parts[0], directory)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Reproduce the host's labels, including scalar arguments, to distinguish
|
|
65
|
+
// repeated reads at different offsets and searches in different directories.
|
|
66
|
+
export function matches(label, part, directory) {
|
|
67
|
+
label = label.trim()
|
|
68
|
+
const s = part.state.input ?? {}
|
|
69
|
+
const file = fileLabel(s.filePath, directory)
|
|
70
|
+
const pending = { read: 'Reading file...', glob: 'Finding files...', grep: 'Searching content...', bash: 'Writing command...', edit: 'Preparing edit...', write: 'Preparing write...', apply_patch: 'Preparing patch...', task: 'Delegating...', question: 'Asking questions...', todowrite: 'Updating todos...', skill: 'Loading skill...', webfetch: 'Fetching from the web...', websearch: 'Searching web...', execute: 'execute' }
|
|
71
|
+
if (running(part) && label === `~ ${pending[part.tool] ?? 'Writing command...'}`) return true
|
|
72
|
+
switch (part.tool) {
|
|
73
|
+
case 'read': return label === `Read ${file} ${scalar(s, ['filePath'])}`.trim()
|
|
74
|
+
case 'glob':
|
|
75
|
+
case 'grep': {
|
|
76
|
+
const name = part.tool === 'glob' ? 'Glob' : 'Grep'
|
|
77
|
+
const prefix = `${name} "${s.pattern}"${s.path ? ` in ${fileLabel(s.path, directory)}` : ''}`
|
|
78
|
+
return label === prefix || label.startsWith(prefix + ' (')
|
|
79
|
+
}
|
|
80
|
+
case 'bash': return label === `$ ${s.command}`.trim() || label === s.command?.trim()
|
|
81
|
+
case 'edit': return label === `← Edit ${file}` || label === `Edit ${file}`
|
|
82
|
+
case 'write': return label === `# Wrote ${file}` || label === `Write ${file}`
|
|
83
|
+
case 'webfetch': return label === `WebFetch ${s.url}`
|
|
84
|
+
case 'websearch': return typeof s.query === 'string' && /^[^\n]+ "/.test(label) && (label.endsWith(`"${s.query}"`) || label.includes(`"${s.query}" (`))
|
|
85
|
+
case 'task': return typeof s.description === 'string' && /Task(?: \(background\))? — /.test(label) && label.split('\n')[0].endsWith(`— ${s.description}`)
|
|
86
|
+
case 'todowrite': return label === '# Todos' || label === 'Updating todos...'
|
|
87
|
+
case 'question': return label === '# Questions' || label === `Asked ${s.questions?.length} question${s.questions?.length === 1 ? '' : 's'}`
|
|
88
|
+
case 'execute': return label === 'execute' || label.startsWith('execute\n')
|
|
89
|
+
case 'apply_patch': return (part.state.status === 'error' && label === 'Patch failed') || (part.state.metadata?.files ?? []).some(file => {
|
|
90
|
+
const expected = file.type === 'delete' ? `# Deleted ${file.relativePath}` : file.type === 'add' ? `# Created ${file.relativePath}` : file.type === 'move' ? `# Moved ${fileLabel(file.filePath, directory)} → ${file.relativePath}` : `← Patched ${file.relativePath}`
|
|
91
|
+
return label === expected
|
|
92
|
+
})
|
|
93
|
+
case 'skill': return label === `Skill "${s.name}"`
|
|
94
|
+
default: return label === `${part.tool} ${scalar(s)}`.trim() || label === `# ${part.tool} ${scalar(s)}`.trim()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default {
|
|
99
|
+
id: 'opencode-fold-tools',
|
|
100
|
+
async tui(api) {
|
|
101
|
+
let sessionID, allOpen = false, serial = 0
|
|
102
|
+
const opened = new Set(), widgets = new Map(), hidden = new Map(), owned = new WeakSet(), nativeHeaders = new Map()
|
|
103
|
+
let disposed = false
|
|
104
|
+
// The host must create tool rows before this plugin can replace them.
|
|
105
|
+
if (api.kv?.get('tool_details_visibility', true) === false) api.kv.set('tool_details_visibility', true)
|
|
106
|
+
const model = () => groups(api.state.session.messages(api.route.current.params?.sessionID), id => api.state.part(id))
|
|
107
|
+
const text = (Sample, content, options = {}) => {
|
|
108
|
+
const node = new Sample(api.renderer, { id: `fold-tools-${++serial}`, content, fg: api.theme.current.text, wrapMode: 'word', flexShrink: 0, ...options })
|
|
109
|
+
owned.add(node); return node
|
|
110
|
+
}
|
|
111
|
+
function remove(widget) {
|
|
112
|
+
if (!widget.root.isDestroyed) {
|
|
113
|
+
if (widget.root.destroyRecursively) widget.root.destroyRecursively(); else widget.root.destroy()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function reset() {
|
|
117
|
+
for (const widget of widgets.values()) remove(widget)
|
|
118
|
+
widgets.clear()
|
|
119
|
+
for (const [node, visible] of hidden) if (!node.isDestroyed) node.visible = visible
|
|
120
|
+
hidden.clear(); opened.clear()
|
|
121
|
+
for (const [node, visible] of nativeHeaders) if (!node.isDestroyed) node.visible = visible
|
|
122
|
+
nativeHeaders.clear()
|
|
123
|
+
}
|
|
124
|
+
function findTranscript(node) {
|
|
125
|
+
if (!node || node.isDestroyed || owned.has(node)) return
|
|
126
|
+
if (node.stickyScroll && node.stickyStart === 'bottom') return node
|
|
127
|
+
if (typeof node.scrollTo === 'function') return
|
|
128
|
+
for (const child of children(node)) { const found = findTranscript(child); if (found) return found }
|
|
129
|
+
}
|
|
130
|
+
function candidates(transcript, userIDs) {
|
|
131
|
+
const found = []
|
|
132
|
+
let parentID
|
|
133
|
+
function visit(node) {
|
|
134
|
+
if (!node || node.isDestroyed || owned.has(node)) return
|
|
135
|
+
if (userIDs.has(node.id)) parentID = node.id
|
|
136
|
+
const kids = children(node)
|
|
137
|
+
for (const row of kids) {
|
|
138
|
+
const peers = children(row)
|
|
139
|
+
if (peers.length === 2 && /^[→←✱⚙$%◈✓│✗]$/.test(plain(peers[0]).trim()) && plain(peers[1])) {
|
|
140
|
+
found.push({ node: peers[1], host: node, label: plain(peers[1]).trim(), parentID })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const head = kids.find(child => plain(child).trim())
|
|
145
|
+
if (head && plain(head).startsWith('~ ')) {
|
|
146
|
+
found.push({ node: head, host: node, label: plain(head).trim(), parentID })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (head && !plain(head).startsWith('# Running in ') && /^(# |← (Edit|Patched) )/.test(plain(head))) {
|
|
150
|
+
found.push({ node: head, host: node, label: plain(head).trim(), parentID, native: /^(← (Edit|Patched) |# (Wrote|Created|Deleted|Moved) )/.test(plain(head)) })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
for (const inner of kids) {
|
|
154
|
+
const command = children(inner)[0]
|
|
155
|
+
if (plain(command).startsWith('$ ')) {
|
|
156
|
+
found.push({ node: command, host: node, label: plain(command).trim(), parentID })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const child of children(node)) visit(child)
|
|
161
|
+
}
|
|
162
|
+
visit(transcript)
|
|
163
|
+
return found
|
|
164
|
+
}
|
|
165
|
+
function sweep() {
|
|
166
|
+
if (disposed) return
|
|
167
|
+
if (api.route.current.name !== 'session') { reset(); sessionID = undefined; return }
|
|
168
|
+
const id = api.route.current.params?.sessionID
|
|
169
|
+
if (id !== sessionID) { reset(); sessionID = id }
|
|
170
|
+
const transcript = findTranscript(api.renderer.root)
|
|
171
|
+
if (!transcript) return
|
|
172
|
+
const list = model(), records = list.flatMap(group => group.parts.map(part => ({ group, part })))
|
|
173
|
+
const userIDs = new Set(api.state.session.messages(id).filter(m => m.role === 'user').map(m => m.id))
|
|
174
|
+
const roots = new Map(), matched = new Set(), usedHosts = new Set(), patchFiles = new Map()
|
|
175
|
+
let cursor = 0
|
|
176
|
+
for (const candidate of candidates(transcript, userIDs)) {
|
|
177
|
+
if (usedHosts.has(candidate.host)) continue
|
|
178
|
+
const index = records.findIndex((r, i) => i >= cursor && (!candidate.parentID || r.group.parentID === candidate.parentID) && matches(candidate.label, r.part, api.state.path.directory))
|
|
179
|
+
if (index < 0) continue
|
|
180
|
+
const record = records[index]
|
|
181
|
+
cursor = index
|
|
182
|
+
if (record.part.tool === 'apply_patch') {
|
|
183
|
+
const seen = patchFiles.get(record.part.id) ?? new Set()
|
|
184
|
+
seen.add(candidate.label); patchFiles.set(record.part.id, seen)
|
|
185
|
+
if (seen.size >= (record.part.state.metadata?.files?.length ?? 1)) cursor++
|
|
186
|
+
} else cursor++
|
|
187
|
+
usedHosts.add(candidate.host); matched.add(candidate.host)
|
|
188
|
+
if (!roots.has(record.group.id)) roots.set(record.group.id, [])
|
|
189
|
+
roots.get(record.group.id).push(candidate)
|
|
190
|
+
}
|
|
191
|
+
for (const [host, visible] of hidden) if (host.isDestroyed || !matched.has(host)) { if (!host.isDestroyed) host.visible = visible; hidden.delete(host) }
|
|
192
|
+
for (const host of matched) { if (!hidden.has(host)) hidden.set(host, host.visible); host.visible = false }
|
|
193
|
+
for (const [key, widget] of widgets) if (!roots.has(key) || widget.root.isDestroyed) { remove(widget); widgets.delete(key) }
|
|
194
|
+
for (const group of list) {
|
|
195
|
+
const anchors = roots.get(group.id)
|
|
196
|
+
if (!anchors?.length) continue
|
|
197
|
+
const anchor = anchors[0]
|
|
198
|
+
let widget = widgets.get(group.id)
|
|
199
|
+
if (!widget) {
|
|
200
|
+
const root = new anchor.host.constructor(api.renderer, { id: `fold-tools-${++serial}`, flexDirection: 'column', paddingLeft: 3, flexShrink: 0 })
|
|
201
|
+
owned.add(root)
|
|
202
|
+
const header = text(anchor.node.constructor, '', { maxHeight: 1, overflow: 'hidden', selectable: false })
|
|
203
|
+
const body = new transcript.constructor(api.renderer, { id: `fold-tools-${++serial}`, height: 14, width: '100%', border: ['left'], borderColor: api.theme.current.borderSubtle, backgroundColor: api.theme.current.backgroundPanel, padding: 1, stickyScroll: false, flexShrink: 0 })
|
|
204
|
+
owned.add(body)
|
|
205
|
+
const content = text(anchor.node.constructor, '', { selectable: true })
|
|
206
|
+
body.add(content); root.add(header); root.add(body)
|
|
207
|
+
const navigation = text(anchor.node.constructor, '打开子任务 →', { selectable: false, fg: api.theme.current.primary })
|
|
208
|
+
root.add(navigation)
|
|
209
|
+
widget = { root, header, body, content, navigation, group }
|
|
210
|
+
widgets.set(group.id, widget)
|
|
211
|
+
if (allOpen) opened.add(group.id)
|
|
212
|
+
header.onMouseUp = event => {
|
|
213
|
+
if (api.renderer.getSelection?.()?.getSelectedText?.()) return
|
|
214
|
+
if (opened.has(group.id)) opened.delete(group.id); else opened.add(group.id)
|
|
215
|
+
refresh(widget); event?.stopPropagation?.()
|
|
216
|
+
}
|
|
217
|
+
navigation.onMouseUp = event => {
|
|
218
|
+
const childID = widget.group.parts[0].state.metadata?.sessionId
|
|
219
|
+
if (childID) api.route.navigate('session', { sessionID: childID })
|
|
220
|
+
event?.stopPropagation?.()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
widget.group = group
|
|
224
|
+
widget.native = ['edit', 'write', 'apply_patch'].includes(group.parts[0].tool) && anchors.every(a => a.native)
|
|
225
|
+
widget.anchors = anchors
|
|
226
|
+
if (widget.native) for (const item of anchors) {
|
|
227
|
+
if (!nativeHeaders.has(item.node)) nativeHeaders.set(item.node, item.node.visible)
|
|
228
|
+
}
|
|
229
|
+
if (widget.root.parent !== anchor.host.parent || children(anchor.host.parent).indexOf(widget.root) + 1 !== children(anchor.host.parent).indexOf(anchor.host)) anchor.host.parent.insertBefore(widget.root, anchor.host)
|
|
230
|
+
refresh(widget)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function refresh(widget) {
|
|
234
|
+
const { group } = widget, open = opened.has(group.id)
|
|
235
|
+
const title = `${open ? '▾' : '▸'} ${groupTitle(group, api.state.path.directory).replace(/\s+/g, ' ')}`
|
|
236
|
+
if (widget.header.content !== title) widget.header.content = title
|
|
237
|
+
widget.root.visible = true
|
|
238
|
+
if (widget.native) for (const anchor of widget.anchors) {
|
|
239
|
+
anchor.host.visible = open
|
|
240
|
+
anchor.node.visible = open ? false : nativeHeaders.get(anchor.node)
|
|
241
|
+
}
|
|
242
|
+
widget.root.backgroundColor = api.theme.current.background
|
|
243
|
+
widget.body.visible = open && !widget.native
|
|
244
|
+
widget.navigation.visible = open && group.parts[0].tool === 'task' && Boolean(group.parts[0].state.metadata?.sessionId)
|
|
245
|
+
if (open) {
|
|
246
|
+
const value = details(group.parts[0])
|
|
247
|
+
if (widget.content.content !== value) widget.content.content = value
|
|
248
|
+
widget.body.height = Math.min(16, Math.max(5, value.split('\n').length + 2))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function picker() {
|
|
252
|
+
const parts = model().flatMap(group => group.parts)
|
|
253
|
+
api.ui.dialog.replace(() => api.ui.DialogSelect({ title: '所有工具详情', options: parts.map((part, index) => ({
|
|
254
|
+
title: `${index + 1}. ${summary(part, api.state.path.directory).replace(/\s+/g, ' ')}`,
|
|
255
|
+
description: part.state.status, value: part.id,
|
|
256
|
+
onSelect: () => api.ui.dialog.replace(() => api.ui.DialogAlert({ title: part.tool, message: details(part, true) })),
|
|
257
|
+
})) }))
|
|
258
|
+
}
|
|
259
|
+
const unregister = api.keymap.registerLayer({ mode: 'base', priority: 110,
|
|
260
|
+
commands: [{ name: 'fold_tools.toggle', title: '展开 / 折叠所有工具', category: 'Plugin', run() {
|
|
261
|
+
allOpen = !allOpen; sweep()
|
|
262
|
+
for (const key of widgets.keys()) { if (allOpen) opened.add(key); else opened.delete(key) }
|
|
263
|
+
for (const widget of widgets.values()) refresh(widget)
|
|
264
|
+
api.ui.toast({ variant: 'info', message: `${allOpen ? '已展开' : '已折叠'} ${widgets.size} 个工具`, duration: 1500 })
|
|
265
|
+
return true
|
|
266
|
+
} }, { name: 'fold_tools.list', title: '所有工具详情', category: 'Plugin', run() { picker(); return true } }],
|
|
267
|
+
bindings: [{ key: 'ctrl+o', cmd: 'fold_tools.toggle', desc: '展开 / 折叠所有工具', preventDefault: true }],
|
|
268
|
+
})
|
|
269
|
+
const off = [api.event.on('message.part.updated', sweep), api.event.on('message.updated', sweep)]
|
|
270
|
+
const timer = setInterval(sweep, 1000)
|
|
271
|
+
api.lifecycle.onDispose(() => { disposed = true; clearInterval(timer); off.forEach(fn => fn()); if (typeof unregister === 'function') unregister(); reset() })
|
|
272
|
+
},
|
|
273
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-fold-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One row per OpenCode tool call. Click to reveal its output, with native file diffs and no exploration grouping.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ningzimu",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ningzimu/opencode-fold-tools.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/ningzimu/opencode-fold-tools#readme",
|
|
13
|
+
"bugs": "https://github.com/ningzimu/opencode-fold-tools/issues",
|
|
14
|
+
"keywords": ["opencode", "opencode-plugin", "tui", "fold", "tool-output"],
|
|
15
|
+
"oc-plugin": ["tui"],
|
|
16
|
+
"exports": {"./tui": "./index.js"},
|
|
17
|
+
"files": ["index.js", "scripts", "README.md", "README.zh-CN.md", "LICENSE", "THIRD_PARTY_NOTICES.md"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test",
|
|
20
|
+
"install:plugin": "node scripts/configure.mjs install",
|
|
21
|
+
"uninstall:plugin": "node scripts/configure.mjs uninstall"
|
|
22
|
+
},
|
|
23
|
+
"engines": {"node": ">=22"},
|
|
24
|
+
"dependencies": {"jsonc-parser": "3.3.1"}
|
|
25
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
import { parse, modify, applyEdits, printParseErrorCode } from 'jsonc-parser'
|
|
7
|
+
|
|
8
|
+
export const pluginPath = fileURLToPath(new URL('../index.js', import.meta.url))
|
|
9
|
+
|
|
10
|
+
export function configPath(explicit, env = process.env) {
|
|
11
|
+
if (explicit || env.OPENCODE_TUI_CONFIG) return path.resolve(explicit || env.OPENCODE_TUI_CONFIG)
|
|
12
|
+
const directory = env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode')
|
|
13
|
+
const json = path.join(directory, 'tui.json'), jsonc = path.join(directory, 'tui.jsonc')
|
|
14
|
+
if (fs.existsSync(json) && fs.existsSync(jsonc)) throw new Error('Both tui.json and tui.jsonc exist. Select one with --config <path>.')
|
|
15
|
+
return fs.existsSync(jsonc) ? jsonc : json
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isThisPlugin(entry, config, target) {
|
|
19
|
+
const spec = Array.isArray(entry) ? entry[0] : entry
|
|
20
|
+
if (typeof spec !== 'string') return false
|
|
21
|
+
if (/^opencode-fold-tools(?:@[^/]+)?$/.test(spec)) return true
|
|
22
|
+
if (!spec.startsWith('file:') && !spec.startsWith('.') && !path.isAbsolute(spec)) return false
|
|
23
|
+
try {
|
|
24
|
+
const location = spec.startsWith('file:') ? fileURLToPath(spec) : path.resolve(path.dirname(config), spec)
|
|
25
|
+
return path.resolve(location) === path.resolve(target)
|
|
26
|
+
} catch { return false }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function configure(config, action, target = pluginPath) {
|
|
30
|
+
if (!['install', 'uninstall'].includes(action)) throw new Error('Action must be install or uninstall.')
|
|
31
|
+
config = path.resolve(config)
|
|
32
|
+
const exists = fs.existsSync(config)
|
|
33
|
+
if (exists) config = fs.realpathSync(config)
|
|
34
|
+
if (!exists && action === 'uninstall') return { changed: false, config }
|
|
35
|
+
const original = exists ? fs.readFileSync(config, 'utf8') : '{}\n'
|
|
36
|
+
const errors = []
|
|
37
|
+
const data = parse(original, errors, { allowTrailingComma: true, disallowComments: false })
|
|
38
|
+
if (errors.length) throw new Error(`Invalid config: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}. No changes made.`)
|
|
39
|
+
if (typeof data !== 'object' || Array.isArray(data) || data === null) throw new Error('Config must be an object. No changes made.')
|
|
40
|
+
if (data.plugin !== undefined && !Array.isArray(data.plugin)) throw new Error('The plugin field must be an array. No changes made.')
|
|
41
|
+
if (action === 'install' && !fs.existsSync(target)) throw new Error(`Plugin file does not exist: ${target}`)
|
|
42
|
+
const spec = pathToFileURL(target).href
|
|
43
|
+
const previous = data.plugin ?? []
|
|
44
|
+
const alreadyInstalled = previous.some(entry => isThisPlugin(entry, config, target))
|
|
45
|
+
const next = action === 'uninstall' ? previous.filter(entry => !isThisPlugin(entry, config, target)) : alreadyInstalled ? previous : [...previous, spec]
|
|
46
|
+
if (JSON.stringify(previous) === JSON.stringify(next)) return { changed: false, config, spec }
|
|
47
|
+
const updated = applyEdits(original, modify(original, ['plugin'], next, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: original.includes('\r\n') ? '\r\n' : '\n' } }))
|
|
48
|
+
fs.mkdirSync(path.dirname(config), { recursive: true })
|
|
49
|
+
let backup
|
|
50
|
+
if (exists) {
|
|
51
|
+
backup = `${config}.fold-tools-backup-${Date.now()}`
|
|
52
|
+
fs.copyFileSync(config, backup, fs.constants.COPYFILE_EXCL)
|
|
53
|
+
fs.chmodSync(backup, 0o600)
|
|
54
|
+
}
|
|
55
|
+
const temporary = `${config}.fold-tools-${process.pid}.tmp`
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(temporary, updated, { flag: 'wx', mode: exists ? fs.statSync(config).mode & 0o777 : 0o600 })
|
|
58
|
+
fs.renameSync(temporary, config)
|
|
59
|
+
} finally {
|
|
60
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary)
|
|
61
|
+
}
|
|
62
|
+
return { changed: true, config, spec, backup }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main(args) {
|
|
66
|
+
if (args.includes('--help') || args.length === 0) {
|
|
67
|
+
console.log('Usage: node scripts/configure.mjs <install|uninstall> [--config /path/to/tui.jsonc]')
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const action = args.shift()
|
|
71
|
+
let explicit
|
|
72
|
+
while (args.length) {
|
|
73
|
+
const flag = args.shift()
|
|
74
|
+
if (flag !== '--config' || !args[0]) throw new Error(`Unknown or incomplete option: ${flag}`)
|
|
75
|
+
explicit = args.shift()
|
|
76
|
+
}
|
|
77
|
+
const result = configure(configPath(explicit), action)
|
|
78
|
+
console.log(`${result.changed ? 'Updated' : 'Unchanged'}: ${result.config}`)
|
|
79
|
+
if (result.backup) console.log(`Backup: ${result.backup}`)
|
|
80
|
+
console.log(action === 'install' ? 'Plugin registered. Keep this checkout and restart OpenCode.' : 'Plugin entry removed. Restart OpenCode; other plugins and settings are unchanged.')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
84
|
+
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
85
|
+
}
|