@lunchbox-labs/opencode-tool-inspector 0.1.0 → 0.2.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/README.md +14 -2
- package/dist/agent-text.d.ts +4 -0
- package/dist/agent-text.js +85 -0
- package/dist/agents.d.ts +79 -0
- package/dist/agents.js +244 -0
- package/dist/detail-viewer.d.ts +6 -0
- package/dist/detail-viewer.js +123 -0
- package/dist/schema-text.d.ts +10 -0
- package/dist/schema-text.js +429 -0
- package/dist/tool-detail.d.ts +17 -0
- package/dist/tool-detail.js +41 -0
- package/dist/tools.d.ts +34 -0
- package/dist/tools.js +119 -0
- package/dist/tui.js +409 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@ Browse the tools available to OpenCode agents from the TUI: each tool's descript
|
|
|
4
4
|
|
|
5
5
|
Requires Node **22.18+**. Built for OpenCode **1.19.x**, starting at **1.19.0**.
|
|
6
6
|
|
|
7
|
-
This plugin is in early development and does not provide any commands yet.
|
|
8
|
-
|
|
9
7
|
## Install
|
|
10
8
|
|
|
11
9
|
Add the package to `plugin` in `tui.jsonc`:
|
|
@@ -13,3 +11,17 @@ Add the package to `plugin` in `tui.jsonc`:
|
|
|
13
11
|
```jsonc
|
|
14
12
|
{ "plugin": ["@lunchbox-labs/opencode-tool-inspector"] }
|
|
15
13
|
```
|
|
14
|
+
|
|
15
|
+
## Browse tools
|
|
16
|
+
|
|
17
|
+
Run `/tools`, or **Browse tools** from the command palette, to list the tools OpenCode offers a model: the one the current session's latest message used, otherwise your configured `model`, otherwise the first connected provider's default. The dialog's title names the model and where it came from. If you switch models before your next prompt, the tools offered may differ.
|
|
18
|
+
|
|
19
|
+
Type to filter the list, and press Enter to see a tool's input schema, the agents that can use it and its full description. The input shows each field's type, whether it's required, its allowed values, default and limits, with nested objects shown under their parent and each option of a union listed with its own fields. Escape returns to the list.
|
|
20
|
+
|
|
21
|
+
The list includes built-in and plugin tools. MCP server tools aren't listed, because OpenCode only adds them when it runs a prompt; a toast says so when an MCP server is connected. If OpenCode can't resolve tools for a model, the list shows tool names only.
|
|
22
|
+
|
|
23
|
+
## Agent access
|
|
24
|
+
|
|
25
|
+
The list isn't filtered by agent. Instead, a tool's detail lists each agent and whether it's allowed to use the tool, asks first, is denied, or isn't offered the tool at all, following each agent's merged `permission` rules and the rules of the current top-level session. Input patterns with different access, such as `read` asking first for `*.env` files, are listed under the agent. Agents with mode `all` also show their access when they run as a subagent, where that differs, since OpenCode always denies subagents any `experimental.primary_tools`, and denies `todowrite` and `task` unless the agent's own rules name them.
|
|
26
|
+
|
|
27
|
+
OpenCode's own hidden agents, such as `compaction` and `title`, are left out. If OpenCode's agents can't be read, details open without this section and a toast says why.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const actionLabels = {
|
|
2
|
+
allow: "allowed",
|
|
3
|
+
ask: "asks first",
|
|
4
|
+
deny: "denied",
|
|
5
|
+
};
|
|
6
|
+
const modeLabels = {
|
|
7
|
+
primary: "primary",
|
|
8
|
+
subagent: "subagent",
|
|
9
|
+
all: "primary or subagent",
|
|
10
|
+
unknown: undefined,
|
|
11
|
+
};
|
|
12
|
+
function muted(text) {
|
|
13
|
+
return { text, tone: "muted" };
|
|
14
|
+
}
|
|
15
|
+
function plain(text) {
|
|
16
|
+
return { text, tone: "plain" };
|
|
17
|
+
}
|
|
18
|
+
function indent(depth) {
|
|
19
|
+
return " ".repeat(depth);
|
|
20
|
+
}
|
|
21
|
+
function decisionSummary(decision) {
|
|
22
|
+
if (decision.access === "unknown") {
|
|
23
|
+
return "unknown, permissions couldn't be read";
|
|
24
|
+
}
|
|
25
|
+
if (decision.offeredToModel === false) {
|
|
26
|
+
return "not offered to the model";
|
|
27
|
+
}
|
|
28
|
+
return actionLabels[decision.access];
|
|
29
|
+
}
|
|
30
|
+
function exceptionLines(decision, depth) {
|
|
31
|
+
if (decision.offeredToModel === false) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return decision.exceptions.map((exception) => [
|
|
35
|
+
plain(`${indent(depth)}${actionLabels[exception.access]} for `),
|
|
36
|
+
{ text: exception.pattern, tone: "name" },
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
function agentLines(agent) {
|
|
40
|
+
const notes = [
|
|
41
|
+
modeLabels[agent.mode],
|
|
42
|
+
...(agent.hidden ? ["hidden"] : []),
|
|
43
|
+
].filter((note) => note !== undefined);
|
|
44
|
+
const lines = [
|
|
45
|
+
[
|
|
46
|
+
{ text: agent.agent, tone: "name" },
|
|
47
|
+
muted(` ${notes.length === 0 ? "" : `${notes.join(", ")}: `}${decisionSummary(agent)}`),
|
|
48
|
+
],
|
|
49
|
+
...exceptionLines(agent, 1),
|
|
50
|
+
];
|
|
51
|
+
if (agent.whenSubagent !== undefined) {
|
|
52
|
+
lines.push([
|
|
53
|
+
plain(`${indent(1)}as a subagent: ${decisionSummary(agent.whenSubagent)}`),
|
|
54
|
+
], ...exceptionLines(agent.whenSubagent, 2));
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
/** OpenCode's own hidden agents, such as `compaction` and `title`, never take tool calls. */
|
|
59
|
+
function listed(agent) {
|
|
60
|
+
return !(agent.native && agent.hidden);
|
|
61
|
+
}
|
|
62
|
+
function primaryFirst(left, right) {
|
|
63
|
+
const rank = (agent) => (agent.mode === "subagent" ? 1 : 0);
|
|
64
|
+
return rank(left) - rank(right);
|
|
65
|
+
}
|
|
66
|
+
/** Renders which agents can use a tool, primary agents first. */
|
|
67
|
+
export function agentAccessLines(access) {
|
|
68
|
+
const agents = access.agents.filter(listed).sort(primaryFirst);
|
|
69
|
+
if (agents.length === 0) {
|
|
70
|
+
return [[muted("OpenCode reported no agents that use tools.")]];
|
|
71
|
+
}
|
|
72
|
+
const permissionNote = access.permission === access.tool
|
|
73
|
+
? []
|
|
74
|
+
: [[muted(`Checked as the ${access.permission} permission.`)]];
|
|
75
|
+
const patternNote = agents.some((agent) => exceptionLines(agent, 1).length > 0 ||
|
|
76
|
+
(agent.whenSubagent !== undefined &&
|
|
77
|
+
exceptionLines(agent.whenSubagent, 2).length > 0))
|
|
78
|
+
? [
|
|
79
|
+
[
|
|
80
|
+
muted("Patterns match the call's input, such as a command or path; a later line wins."),
|
|
81
|
+
],
|
|
82
|
+
]
|
|
83
|
+
: [];
|
|
84
|
+
return [...permissionNote, ...agents.flatMap(agentLines), ...patternNote];
|
|
85
|
+
}
|
package/dist/agents.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Works out which agents may use each tool, following the permission rules
|
|
3
|
+
* OpenCode 1.19 applies when it offers tools to a model and when a tool asks
|
|
4
|
+
* for permission.
|
|
5
|
+
*
|
|
6
|
+
* Pass the agents from `client.app.agents()` and either the items of
|
|
7
|
+
* `client.tool.list({ provider, model })` or the ids from `client.tool.ids()`.
|
|
8
|
+
* Each agent's `permission` ruleset already combines OpenCode's defaults, the
|
|
9
|
+
* global `permission` and legacy `tools` settings and the agent's own rules,
|
|
10
|
+
* and disabled agents are already left out. Only permissions are applied here:
|
|
11
|
+
* OpenCode also withholds some tools depending on the model, such as
|
|
12
|
+
* `websearch` without a search provider, and `tool.list` already leaves those
|
|
13
|
+
* out for the model it is given.
|
|
14
|
+
*/
|
|
15
|
+
export type PermissionAction = "allow" | "ask" | "deny";
|
|
16
|
+
export type ToolAccess = PermissionAction | "unknown";
|
|
17
|
+
export type AgentMode = "primary" | "subagent" | "all" | "unknown";
|
|
18
|
+
export interface PermissionRuleInput {
|
|
19
|
+
readonly permission: string;
|
|
20
|
+
readonly pattern: string;
|
|
21
|
+
readonly action: PermissionAction;
|
|
22
|
+
}
|
|
23
|
+
export interface AgentInput {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly mode?: string;
|
|
26
|
+
readonly hidden?: boolean;
|
|
27
|
+
readonly native?: boolean;
|
|
28
|
+
readonly permission?: unknown;
|
|
29
|
+
}
|
|
30
|
+
export type ToolInput = string | {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
};
|
|
33
|
+
export interface ToolAgentAccessOptions {
|
|
34
|
+
/** `experimental.primary_tools` from the OpenCode config: tools denied to agents running as subagents. */
|
|
35
|
+
readonly primaryTools?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* The permission ruleset of the session the agents would run in, which OpenCode applies after the
|
|
38
|
+
* agent's rules. Pass a top-level session's ruleset: when it spawns a subagent, OpenCode copies only
|
|
39
|
+
* its deny and `external_directory` rules into the subagent's session.
|
|
40
|
+
*/
|
|
41
|
+
readonly sessionPermission?: unknown;
|
|
42
|
+
}
|
|
43
|
+
/** An input pattern whose access differs from the tool's general access. */
|
|
44
|
+
export interface PatternAccess {
|
|
45
|
+
readonly pattern: string;
|
|
46
|
+
readonly access: PermissionAction;
|
|
47
|
+
}
|
|
48
|
+
export interface AccessDecision {
|
|
49
|
+
/**
|
|
50
|
+
* Access for inputs that no pattern in `exceptions` matches. OpenCode can still ask at call time for
|
|
51
|
+
* paths outside the project (the `external_directory` permission), and an `ask` the user already
|
|
52
|
+
* answered with "always" may already be allowed for matching calls.
|
|
53
|
+
*/
|
|
54
|
+
readonly access: ToolAccess;
|
|
55
|
+
/** Pattern-specific access in OpenCode's precedence order: a later entry wins over an earlier one when both match. */
|
|
56
|
+
readonly exceptions: readonly PatternAccess[];
|
|
57
|
+
/**
|
|
58
|
+
* Whether OpenCode offers the tool to the model at all. It withholds a tool only when the last rule for
|
|
59
|
+
* its permission denies the `*` pattern, so a tool can be offered even when every call is denied.
|
|
60
|
+
* `undefined` when the rules can't be read.
|
|
61
|
+
*/
|
|
62
|
+
readonly offeredToModel: boolean | undefined;
|
|
63
|
+
}
|
|
64
|
+
export interface AgentToolAccess extends AccessDecision {
|
|
65
|
+
readonly agent: string;
|
|
66
|
+
readonly mode: AgentMode;
|
|
67
|
+
readonly hidden: boolean;
|
|
68
|
+
readonly native: boolean;
|
|
69
|
+
/** For agents with mode `all`: access when the agent runs as a subagent, present only when it differs. */
|
|
70
|
+
readonly whenSubagent?: AccessDecision;
|
|
71
|
+
}
|
|
72
|
+
export interface ToolAgentAccess {
|
|
73
|
+
readonly tool: string;
|
|
74
|
+
/** The permission name OpenCode checks for this tool, such as `edit` for `write`. */
|
|
75
|
+
readonly permission: string;
|
|
76
|
+
readonly agents: readonly AgentToolAccess[];
|
|
77
|
+
}
|
|
78
|
+
export declare function permissionForTool(toolId: string): string;
|
|
79
|
+
export declare function resolveToolAgentAccess(tools: readonly ToolInput[], agents: readonly AgentInput[], options?: ToolAgentAccessOptions): ToolAgentAccess[];
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Works out which agents may use each tool, following the permission rules
|
|
3
|
+
* OpenCode 1.19 applies when it offers tools to a model and when a tool asks
|
|
4
|
+
* for permission.
|
|
5
|
+
*
|
|
6
|
+
* Pass the agents from `client.app.agents()` and either the items of
|
|
7
|
+
* `client.tool.list({ provider, model })` or the ids from `client.tool.ids()`.
|
|
8
|
+
* Each agent's `permission` ruleset already combines OpenCode's defaults, the
|
|
9
|
+
* global `permission` and legacy `tools` settings and the agent's own rules,
|
|
10
|
+
* and disabled agents are already left out. Only permissions are applied here:
|
|
11
|
+
* OpenCode also withholds some tools depending on the model, such as
|
|
12
|
+
* `websearch` without a search provider, and `tool.list` already leaves those
|
|
13
|
+
* out for the model it is given.
|
|
14
|
+
*/
|
|
15
|
+
const editTools = new Set(["edit", "write", "apply_patch"]);
|
|
16
|
+
const mcpResourceReadTools = new Set([
|
|
17
|
+
"list_mcp_resources",
|
|
18
|
+
"list_mcp_resource_templates",
|
|
19
|
+
"read_mcp_resource",
|
|
20
|
+
]);
|
|
21
|
+
const unknownDecision = {
|
|
22
|
+
access: "unknown",
|
|
23
|
+
exceptions: [],
|
|
24
|
+
offeredToModel: undefined,
|
|
25
|
+
};
|
|
26
|
+
const compiledWildcards = new Map();
|
|
27
|
+
function field(value, key) {
|
|
28
|
+
return typeof value === "object" && value !== null
|
|
29
|
+
? Reflect.get(value, key)
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
function isPermissionAction(value) {
|
|
33
|
+
return value === "allow" || value === "ask" || value === "deny";
|
|
34
|
+
}
|
|
35
|
+
function readRule(value) {
|
|
36
|
+
const permission = field(value, "permission");
|
|
37
|
+
const pattern = field(value, "pattern");
|
|
38
|
+
const action = field(value, "action");
|
|
39
|
+
if (typeof permission !== "string" ||
|
|
40
|
+
typeof pattern !== "string" ||
|
|
41
|
+
!isPermissionAction(action)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return { permission, pattern, action };
|
|
45
|
+
}
|
|
46
|
+
function readRuleset(value) {
|
|
47
|
+
if (!Array.isArray(value)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const rules = [];
|
|
51
|
+
for (const item of value) {
|
|
52
|
+
const rule = readRule(item);
|
|
53
|
+
if (rule === undefined) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
rules.push(rule);
|
|
57
|
+
}
|
|
58
|
+
return rules;
|
|
59
|
+
}
|
|
60
|
+
function readStringList(value) {
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const strings = [];
|
|
68
|
+
for (const item of value) {
|
|
69
|
+
if (typeof item !== "string") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
strings.push(item);
|
|
73
|
+
}
|
|
74
|
+
return strings;
|
|
75
|
+
}
|
|
76
|
+
function readItems(value) {
|
|
77
|
+
return Array.isArray(value) ? value : [];
|
|
78
|
+
}
|
|
79
|
+
function readAgent(value) {
|
|
80
|
+
const name = field(value, "name");
|
|
81
|
+
if (typeof name !== "string") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const mode = field(value, "mode");
|
|
85
|
+
return {
|
|
86
|
+
name,
|
|
87
|
+
mode: typeof mode === "string" ? mode : undefined,
|
|
88
|
+
hidden: field(value, "hidden") === true,
|
|
89
|
+
native: field(value, "native") === true,
|
|
90
|
+
permission: field(value, "permission"),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function readToolId(value) {
|
|
94
|
+
const id = typeof value === "string" ? value : field(value, "id");
|
|
95
|
+
return typeof id === "string" ? id : undefined;
|
|
96
|
+
}
|
|
97
|
+
function readMode(mode) {
|
|
98
|
+
return mode === "primary" || mode === "subagent" || mode === "all"
|
|
99
|
+
? mode
|
|
100
|
+
: "unknown";
|
|
101
|
+
}
|
|
102
|
+
function runsOnWindows() {
|
|
103
|
+
return typeof process !== "undefined" && process.platform === "win32";
|
|
104
|
+
}
|
|
105
|
+
// Same matching as OpenCode's wildcard helper, including its trailing " *"
|
|
106
|
+
// special case, slash normalization and case-insensitive matching on Windows.
|
|
107
|
+
function compileWildcard(pattern, flags) {
|
|
108
|
+
let expression = pattern
|
|
109
|
+
.replaceAll("\\", "/")
|
|
110
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
111
|
+
.replace(/\*/g, ".*")
|
|
112
|
+
.replace(/\?/g, ".");
|
|
113
|
+
if (expression.endsWith(" .*")) {
|
|
114
|
+
expression = `${expression.slice(0, -3)}( .*)?`;
|
|
115
|
+
}
|
|
116
|
+
return new RegExp(`^${expression}$`, flags);
|
|
117
|
+
}
|
|
118
|
+
function matchesWildcard(input, pattern) {
|
|
119
|
+
const flags = runsOnWindows() ? "si" : "s";
|
|
120
|
+
const key = `${flags}:${pattern}`;
|
|
121
|
+
let compiled = compiledWildcards.get(key);
|
|
122
|
+
if (compiled === undefined) {
|
|
123
|
+
compiled = compileWildcard(pattern, flags);
|
|
124
|
+
compiledWildcards.set(key, compiled);
|
|
125
|
+
}
|
|
126
|
+
return compiled.test(input.replaceAll("\\", "/"));
|
|
127
|
+
}
|
|
128
|
+
// A trailing " *" also matches nothing, so "* *" covers every input too.
|
|
129
|
+
function isCatchAllPattern(pattern) {
|
|
130
|
+
return /^\*+(?: \*)?$/.test(pattern);
|
|
131
|
+
}
|
|
132
|
+
function decide(permission, rules) {
|
|
133
|
+
const matching = rules.filter((rule) => matchesWildcard(permission, rule.permission));
|
|
134
|
+
const lastCatchAll = matching.findLastIndex((rule) => isCatchAllPattern(rule.pattern));
|
|
135
|
+
const access = matching[lastCatchAll]?.action ?? "ask";
|
|
136
|
+
const exceptions = [];
|
|
137
|
+
for (const rule of matching.slice(lastCatchAll + 1)) {
|
|
138
|
+
const earlier = exceptions.findIndex((exception) => exception.pattern === rule.pattern);
|
|
139
|
+
if (earlier !== -1) {
|
|
140
|
+
exceptions.splice(earlier, 1);
|
|
141
|
+
}
|
|
142
|
+
if (exceptions.length === 0 && rule.action === access) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
exceptions.push({ pattern: rule.pattern, access: rule.action });
|
|
146
|
+
}
|
|
147
|
+
const firstChange = exceptions.findIndex((exception) => exception.access !== access);
|
|
148
|
+
const lastRule = matching.at(-1);
|
|
149
|
+
return {
|
|
150
|
+
access,
|
|
151
|
+
exceptions: firstChange === -1 ? [] : exceptions.slice(firstChange),
|
|
152
|
+
offeredToModel: !(lastRule?.pattern === "*" && lastRule.action === "deny"),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function sameDecision(first, second) {
|
|
156
|
+
return (first.access === second.access &&
|
|
157
|
+
first.offeredToModel === second.offeredToModel &&
|
|
158
|
+
first.exceptions.length === second.exceptions.length &&
|
|
159
|
+
first.exceptions.every((exception, index) => exception.pattern === second.exceptions[index]?.pattern &&
|
|
160
|
+
exception.access === second.exceptions[index].access));
|
|
161
|
+
}
|
|
162
|
+
function denyAll(permission) {
|
|
163
|
+
return { permission, pattern: "*", action: "deny" };
|
|
164
|
+
}
|
|
165
|
+
// Mirrors the session rules OpenCode's task tool gives a subagent.
|
|
166
|
+
function subagentSessionRules(agentRules, parentSessionRules, primaryTools) {
|
|
167
|
+
const namesPermission = (permission) => agentRules.some((rule) => rule.permission === permission);
|
|
168
|
+
const inherited = [
|
|
169
|
+
...parentSessionRules.filter((rule) => rule.action === "deny" || rule.permission === "external_directory"),
|
|
170
|
+
...["todowrite", "task"]
|
|
171
|
+
.filter((permission) => !namesPermission(permission))
|
|
172
|
+
.map(denyAll),
|
|
173
|
+
];
|
|
174
|
+
const isInherited = (deny) => inherited.some((rule) => rule.permission === deny.permission &&
|
|
175
|
+
rule.pattern === deny.pattern &&
|
|
176
|
+
rule.action === deny.action);
|
|
177
|
+
return [
|
|
178
|
+
...inherited,
|
|
179
|
+
...primaryTools.map(denyAll).filter((deny) => !isInherited(deny)),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
function agentAccess(agent, permission, sessionRules, primaryTools) {
|
|
183
|
+
const mode = readMode(agent.mode);
|
|
184
|
+
const summary = {
|
|
185
|
+
agent: agent.name,
|
|
186
|
+
mode,
|
|
187
|
+
hidden: agent.hidden === true,
|
|
188
|
+
native: agent.native === true,
|
|
189
|
+
};
|
|
190
|
+
const agentRules = readRuleset(agent.permission);
|
|
191
|
+
if (agentRules === undefined) {
|
|
192
|
+
return { ...summary, ...unknownDecision };
|
|
193
|
+
}
|
|
194
|
+
const primaryDecision = () => sessionRules === undefined
|
|
195
|
+
? unknownDecision
|
|
196
|
+
: decide(permission, [...agentRules, ...sessionRules]);
|
|
197
|
+
const subagentDecision = () => primaryTools === undefined || sessionRules === undefined
|
|
198
|
+
? unknownDecision
|
|
199
|
+
: decide(permission, [
|
|
200
|
+
...agentRules,
|
|
201
|
+
...subagentSessionRules(agentRules, sessionRules, primaryTools),
|
|
202
|
+
]);
|
|
203
|
+
switch (mode) {
|
|
204
|
+
case "subagent":
|
|
205
|
+
return { ...summary, ...subagentDecision() };
|
|
206
|
+
case "all": {
|
|
207
|
+
const asPrimary = primaryDecision();
|
|
208
|
+
const asSubagent = subagentDecision();
|
|
209
|
+
return sameDecision(asPrimary, asSubagent)
|
|
210
|
+
? { ...summary, ...asPrimary }
|
|
211
|
+
: { ...summary, ...asPrimary, whenSubagent: asSubagent };
|
|
212
|
+
}
|
|
213
|
+
case "primary":
|
|
214
|
+
case "unknown":
|
|
215
|
+
return { ...summary, ...primaryDecision() };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function permissionForTool(toolId) {
|
|
219
|
+
if (editTools.has(toolId)) {
|
|
220
|
+
return "edit";
|
|
221
|
+
}
|
|
222
|
+
if (mcpResourceReadTools.has(toolId)) {
|
|
223
|
+
return "read";
|
|
224
|
+
}
|
|
225
|
+
return toolId;
|
|
226
|
+
}
|
|
227
|
+
export function resolveToolAgentAccess(tools, agents, options = {}) {
|
|
228
|
+
const sessionRules = readRuleset(field(options, "sessionPermission") ?? []);
|
|
229
|
+
const primaryTools = readStringList(field(options, "primaryTools"));
|
|
230
|
+
const readableAgents = readItems(agents)
|
|
231
|
+
.map(readAgent)
|
|
232
|
+
.filter((agent) => agent !== undefined);
|
|
233
|
+
return readItems(tools)
|
|
234
|
+
.map(readToolId)
|
|
235
|
+
.filter((tool) => tool !== undefined)
|
|
236
|
+
.map((tool) => {
|
|
237
|
+
const permission = permissionForTool(tool);
|
|
238
|
+
return {
|
|
239
|
+
tool,
|
|
240
|
+
permission,
|
|
241
|
+
agents: readableAgents.map((agent) => agentAccess(agent, permission, sessionRules, primaryTools)),
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import type { ToolDetail } from "./tool-detail.ts";
|
|
3
|
+
type DialogElement = ReturnType<TuiPluginApi["ui"]["DialogAlert"]>;
|
|
4
|
+
/** Renders a tool's detail sections in a keyboard-scrollable box inside the host's dialog frame. */
|
|
5
|
+
export declare function createDetailViewer(api: TuiPluginApi): Promise<(detail: ToolDetail) => DialogElement>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The host dialog starts a quarter down the screen; its padding plus this view's
|
|
2
|
+
// title, footer, gaps and a margin clear of the status bar take the remaining rows.
|
|
3
|
+
const chromeRows = 9;
|
|
4
|
+
/** Renders a tool's detail sections in a keyboard-scrollable box inside the host's dialog frame. */
|
|
5
|
+
export async function createDetailViewer(api) {
|
|
6
|
+
// OpenCode supplies its shared runtime; importing this entrypoint stays Node-safe.
|
|
7
|
+
const { BoxRenderable, RenderableEvents, ScrollBoxRenderable, StyledText, TextAttributes, TextRenderable, } = await import("@opentui/core");
|
|
8
|
+
return (detail) => {
|
|
9
|
+
const { renderer } = api;
|
|
10
|
+
const theme = api.theme.current;
|
|
11
|
+
const toneColours = {
|
|
12
|
+
plain: theme.text,
|
|
13
|
+
name: theme.text,
|
|
14
|
+
muted: theme.textMuted,
|
|
15
|
+
};
|
|
16
|
+
const styled = (lines) => new StyledText(lines.flatMap((line, index) => [
|
|
17
|
+
...(index === 0 ? [] : [{ __isChunk: true, text: "\n" }]),
|
|
18
|
+
...line.map((segment) => ({
|
|
19
|
+
__isChunk: true,
|
|
20
|
+
text: segment.text,
|
|
21
|
+
fg: toneColours[segment.tone],
|
|
22
|
+
...(segment.tone === "name"
|
|
23
|
+
? { attributes: TextAttributes.BOLD }
|
|
24
|
+
: {}),
|
|
25
|
+
})),
|
|
26
|
+
]));
|
|
27
|
+
const rows = () => Math.max(1, renderer.height - Math.floor(renderer.height / 4) - chromeRows);
|
|
28
|
+
const view = new BoxRenderable(renderer, {
|
|
29
|
+
flexDirection: "column",
|
|
30
|
+
paddingX: 2,
|
|
31
|
+
paddingBottom: 1,
|
|
32
|
+
gap: 1,
|
|
33
|
+
});
|
|
34
|
+
view.add(new TextRenderable(renderer, {
|
|
35
|
+
content: detail.title,
|
|
36
|
+
fg: theme.text,
|
|
37
|
+
attributes: TextAttributes.BOLD,
|
|
38
|
+
selectable: false,
|
|
39
|
+
}));
|
|
40
|
+
const body = new BoxRenderable(renderer, {
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
gap: 1,
|
|
43
|
+
});
|
|
44
|
+
if (detail.subtitle !== undefined) {
|
|
45
|
+
body.add(new TextRenderable(renderer, {
|
|
46
|
+
content: detail.subtitle,
|
|
47
|
+
fg: theme.textMuted,
|
|
48
|
+
wrapMode: "word",
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
for (const section of detail.sections) {
|
|
52
|
+
const block = new BoxRenderable(renderer, { flexDirection: "column" });
|
|
53
|
+
block.add(new TextRenderable(renderer, {
|
|
54
|
+
content: section.heading,
|
|
55
|
+
fg: theme.accent,
|
|
56
|
+
attributes: TextAttributes.BOLD,
|
|
57
|
+
selectable: false,
|
|
58
|
+
}));
|
|
59
|
+
block.add(new TextRenderable(renderer, {
|
|
60
|
+
content: styled(section.lines),
|
|
61
|
+
fg: theme.text,
|
|
62
|
+
wrapMode: "word",
|
|
63
|
+
}));
|
|
64
|
+
body.add(block);
|
|
65
|
+
}
|
|
66
|
+
const unwrappedRows = detail.sections.reduce((total, section) => total + section.lines.length + 2, detail.subtitle === undefined ? 0 : 2);
|
|
67
|
+
const scroll = new ScrollBoxRenderable(renderer, {
|
|
68
|
+
height: Math.min(rows(), unwrappedRows),
|
|
69
|
+
flexShrink: 0,
|
|
70
|
+
verticalScrollbarOptions: {
|
|
71
|
+
paddingLeft: 1,
|
|
72
|
+
trackOptions: {
|
|
73
|
+
backgroundColor: theme.backgroundElement,
|
|
74
|
+
foregroundColor: theme.border,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
// Short details keep a compact dialog; long ones scroll within the terminal.
|
|
79
|
+
const fit = () => {
|
|
80
|
+
if (!scroll.isDestroyed) {
|
|
81
|
+
scroll.height = Math.max(1, Math.min(rows(), body.height));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
body.onSizeChange = fit;
|
|
85
|
+
scroll.add(body);
|
|
86
|
+
view.add(scroll);
|
|
87
|
+
view.add(new TextRenderable(renderer, {
|
|
88
|
+
content: "↑/↓ scroll · pgup/pgdn page · home/end · esc back",
|
|
89
|
+
fg: theme.textMuted,
|
|
90
|
+
selectable: false,
|
|
91
|
+
}));
|
|
92
|
+
renderer.on("resize", fit);
|
|
93
|
+
// The session's global paging keys close dialogs; a later layer on the
|
|
94
|
+
// focused box takes precedence, as the host's own DialogSelect does.
|
|
95
|
+
const unbind = api.keymap.registerLayer({
|
|
96
|
+
target: scroll,
|
|
97
|
+
targetMode: "focus",
|
|
98
|
+
bindings: [
|
|
99
|
+
{ key: "pageup", cmd: () => scroll.scrollBy(-1 / 2, "viewport") },
|
|
100
|
+
{ key: "pagedown", cmd: () => scroll.scrollBy(1 / 2, "viewport") },
|
|
101
|
+
{ key: "home", cmd: () => scroll.scrollBy(-1, "content") },
|
|
102
|
+
{ key: "end", cmd: () => scroll.scrollBy(1, "content") },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
// Escape first restores the host's previous input focus, as after native DialogSelect.
|
|
106
|
+
const focus = setTimeout(() => {
|
|
107
|
+
if (!scroll.isDestroyed && !api.lifecycle.signal.aborted) {
|
|
108
|
+
scroll.focus();
|
|
109
|
+
}
|
|
110
|
+
}, 1);
|
|
111
|
+
const dispose = () => view.destroyRecursively();
|
|
112
|
+
api.lifecycle.signal.addEventListener("abort", dispose, { once: true });
|
|
113
|
+
view.on(RenderableEvents.DESTROYED, () => {
|
|
114
|
+
clearTimeout(focus);
|
|
115
|
+
renderer.off("resize", fit);
|
|
116
|
+
unbind();
|
|
117
|
+
api.lifecycle.signal.removeEventListener("abort", dispose);
|
|
118
|
+
});
|
|
119
|
+
// OpenTUI accepts Renderable children; Solid's shared JSX type still names DOM nodes.
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenTUI's reconciler inserts Renderable instances directly.
|
|
121
|
+
return view;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TextTone = "plain" | "name" | "muted";
|
|
2
|
+
export interface TextSegment {
|
|
3
|
+
text: string;
|
|
4
|
+
tone: TextTone;
|
|
5
|
+
}
|
|
6
|
+
/** A line of rendered text, split into toned segments so the viewer can colour names and types. */
|
|
7
|
+
export type TextLine = TextSegment[];
|
|
8
|
+
/** Renders a tool's input JSON schema as indented, readable lines. */
|
|
9
|
+
export declare function schemaLines(schema: unknown): TextLine[];
|
|
10
|
+
export declare function linesText(lines: readonly TextLine[]): string;
|