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 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.
@@ -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
+ }