@uniqbit/mate-opencode-plugin 0.15.0-canary.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/package.json +40 -0
- package/src/add-dir.ts +41 -0
- package/src/companion-hooks.ts +370 -0
- package/src/companion-policy.ts +177 -0
- package/src/companion.ts +141 -0
- package/src/server.ts +54 -0
- package/src/tui.tsx +73 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniqbit/mate-opencode-plugin",
|
|
3
|
+
"version": "0.15.0-canary.0",
|
|
4
|
+
"description": "Mate OpenCode runtime plugins: aggregate regular plugin (./server) and TUI plugin (./tui).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/",
|
|
8
|
+
"!src/**/*.test.ts"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./server": "./src/server.ts",
|
|
13
|
+
"./tui": "./src/tui.tsx"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"registry": "https://registry.npmjs.org/"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "CI=1 bun test",
|
|
21
|
+
"lint": "oxlint src/",
|
|
22
|
+
"lint:fix": "oxlint src/ --fix",
|
|
23
|
+
"format": "oxfmt src/",
|
|
24
|
+
"format:check": "oxfmt src/ --check"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "^1.18.3",
|
|
28
|
+
"@opentui/core": "^0.4.5",
|
|
29
|
+
"@opentui/keymap": "^0.4.5",
|
|
30
|
+
"@opentui/solid": "^0.4.5",
|
|
31
|
+
"@uniqbit/mate-core": "0.15.0-canary.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "^1.3.14"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": "24.x"
|
|
38
|
+
},
|
|
39
|
+
"packageManager": "bun@1.3.14"
|
|
40
|
+
}
|
package/src/add-dir.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Config, Hooks, Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
|
|
5
|
+
// OpenCode accepts a per-path rule map for external_directory at runtime even
|
|
6
|
+
// though the published Config type narrows it to a single rule value.
|
|
7
|
+
type ExternalDirectoryRules = Record<string, "allow" | "ask" | "deny" | undefined>;
|
|
8
|
+
type ConfigHook = NonNullable<Hooks["config"]>;
|
|
9
|
+
|
|
10
|
+
function companionPathRules(companionPath: string): string[] {
|
|
11
|
+
return [companionPath, `${companionPath}/**`];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function externalDirectoryRules(config: Config): ExternalDirectoryRules {
|
|
15
|
+
const permission = (config.permission ??= {}) as { external_directory?: unknown };
|
|
16
|
+
permission.external_directory ??= {};
|
|
17
|
+
return permission.external_directory as ExternalDirectoryRules;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const AddDirPlugin: Plugin = async () => {
|
|
21
|
+
const companionPath = process.env.MATE_ARTIFACT_PATH?.trim();
|
|
22
|
+
if (!companionPath) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resolvedCompanionPath = path.resolve(companionPath);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
config: (async (cfg) => {
|
|
30
|
+
const externalDirectory = externalDirectoryRules(cfg);
|
|
31
|
+
|
|
32
|
+
for (const rule of companionPathRules(resolvedCompanionPath)) {
|
|
33
|
+
if (externalDirectory[rule] === undefined) {
|
|
34
|
+
externalDirectory[rule] = "allow";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}) satisfies ConfigHook,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default AddDirPlugin;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildArtifactError,
|
|
8
|
+
extractPatchPaths,
|
|
9
|
+
readContext,
|
|
10
|
+
shouldBlockArtifactWrite,
|
|
11
|
+
type CompanionContext,
|
|
12
|
+
} from "./companion-policy";
|
|
13
|
+
|
|
14
|
+
const ARCHIVE_ENTRY_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
|
|
15
|
+
const ARCHIVE_PATH_PATTERN =
|
|
16
|
+
/(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
|
|
17
|
+
|
|
18
|
+
function readArchiveEntries(archiveDir: string): Set<string> {
|
|
19
|
+
try {
|
|
20
|
+
return new Set(
|
|
21
|
+
fs
|
|
22
|
+
.readdirSync(archiveDir, { withFileTypes: true })
|
|
23
|
+
.filter((entry) => entry.isDirectory() && ARCHIVE_ENTRY_PATTERN.test(entry.name))
|
|
24
|
+
.map((entry) => entry.name),
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
return new Set();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shellSplit(command: string): string[] {
|
|
32
|
+
const parts: string[] = [];
|
|
33
|
+
let current = "";
|
|
34
|
+
let quote: "'" | '"' | null = null;
|
|
35
|
+
let escaping = false;
|
|
36
|
+
|
|
37
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
38
|
+
const char = command[index];
|
|
39
|
+
if (escaping) {
|
|
40
|
+
current += char;
|
|
41
|
+
escaping = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === "\\" && quote !== "'") {
|
|
45
|
+
const next = command[index + 1] ?? "";
|
|
46
|
+
if (/^[\\\s'"]$/.test(next)) {
|
|
47
|
+
escaping = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
current += char;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if ((char === "'" || char === '"') && !quote) {
|
|
54
|
+
quote = char;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (quote === char) {
|
|
58
|
+
quote = null;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!quote && /\s/.test(char)) {
|
|
62
|
+
if (current) {
|
|
63
|
+
parts.push(current);
|
|
64
|
+
current = "";
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
current += char;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (current) parts.push(current);
|
|
72
|
+
return parts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractArchivedChangeFromPath(value: string): string | null {
|
|
76
|
+
const normalized = value.replace(/\\/g, "/");
|
|
77
|
+
const match = normalized.match(ARCHIVE_PATH_PATTERN);
|
|
78
|
+
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractArchiveCommandChange(command: string): string | null {
|
|
82
|
+
const parts = shellSplit(command);
|
|
83
|
+
const archiveIndex = parts.findIndex(
|
|
84
|
+
(part, index) =>
|
|
85
|
+
part === "archive" &&
|
|
86
|
+
index > 0 &&
|
|
87
|
+
(parts[index - 1] === "openspec" || parts[index - 1].endsWith("/openspec")),
|
|
88
|
+
);
|
|
89
|
+
if (archiveIndex < 0) return null;
|
|
90
|
+
|
|
91
|
+
for (let index = archiveIndex + 1; index < parts.length; index += 1) {
|
|
92
|
+
const part = parts[index];
|
|
93
|
+
if (!part || part === "--") continue;
|
|
94
|
+
if (part.startsWith("-")) {
|
|
95
|
+
if (part === "--store") index += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return part;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractMoveArchiveChange(commandText: string): string | null {
|
|
104
|
+
const parts = shellSplit(commandText);
|
|
105
|
+
const moveCommands = new Set([
|
|
106
|
+
"mv",
|
|
107
|
+
"move",
|
|
108
|
+
"move-item",
|
|
109
|
+
"cmd",
|
|
110
|
+
"cmd.exe",
|
|
111
|
+
"powershell",
|
|
112
|
+
"powershell.exe",
|
|
113
|
+
"pwsh",
|
|
114
|
+
"pwsh.exe",
|
|
115
|
+
]);
|
|
116
|
+
const isPython = (part: string) =>
|
|
117
|
+
/^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() ?? part);
|
|
118
|
+
|
|
119
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
120
|
+
const command = parts[index].toLowerCase();
|
|
121
|
+
if (moveCommands.has(command)) {
|
|
122
|
+
for (const part of parts.slice(index + 1)) {
|
|
123
|
+
const change = extractArchivedChangeFromPath(part);
|
|
124
|
+
if (change) return change;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(commandText)) {
|
|
128
|
+
return extractArchivedChangeFromPath(commandText);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectCommands(value: unknown): string[] {
|
|
135
|
+
if (!value || typeof value !== "object") return [];
|
|
136
|
+
const record = value as Record<string, unknown>;
|
|
137
|
+
const commands: string[] = [];
|
|
138
|
+
if (typeof record.command === "string") commands.push(record.command);
|
|
139
|
+
for (const key of ["args", "tool_input"] as const) {
|
|
140
|
+
const nested = record[key];
|
|
141
|
+
if (nested && typeof nested === "object") {
|
|
142
|
+
const command = (nested as Record<string, unknown>).command;
|
|
143
|
+
if (typeof command === "string") commands.push(command);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return commands;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function commandSucceeded(value: unknown): boolean {
|
|
150
|
+
if (!value || typeof value !== "object") return true;
|
|
151
|
+
const record = value as Record<string, unknown>;
|
|
152
|
+
const candidates = [
|
|
153
|
+
record,
|
|
154
|
+
record.result,
|
|
155
|
+
record.output,
|
|
156
|
+
record.tool_output,
|
|
157
|
+
record.tool_response,
|
|
158
|
+
];
|
|
159
|
+
for (const candidate of candidates) {
|
|
160
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
161
|
+
const result = candidate as Record<string, unknown>;
|
|
162
|
+
if (typeof result.success === "boolean") return result.success;
|
|
163
|
+
for (const key of ["exitCode", "exit_code", "status", "statusCode", "status_code"] as const) {
|
|
164
|
+
const status = result[key];
|
|
165
|
+
if (typeof status === "number") return status === 0;
|
|
166
|
+
if (typeof status === "string" && status.trim()) return Number(status) === 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function detectCommandArchive(input: unknown, output: unknown): string | null {
|
|
173
|
+
if (!commandSucceeded(input) || !commandSucceeded(output)) return null;
|
|
174
|
+
for (const command of [...collectCommands(input), ...collectCommands(output)]) {
|
|
175
|
+
const change = extractArchiveCommandChange(command) ?? extractMoveArchiveChange(command);
|
|
176
|
+
if (change) return change;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function detectNewlyArchivedChanges(archiveDir: string, snapshot: Set<string>): string[] {
|
|
182
|
+
const current = readArchiveEntries(archiveDir);
|
|
183
|
+
const newlyArchived = [...current].filter((entry) => !snapshot.has(entry)).toSorted();
|
|
184
|
+
snapshot.clear();
|
|
185
|
+
for (const entry of current) snapshot.add(entry);
|
|
186
|
+
return newlyArchived;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function appendOpenSpecFinishNudge(
|
|
190
|
+
context: CompanionContext,
|
|
191
|
+
archiveSnapshot: Set<string>,
|
|
192
|
+
nudgedCommandChanges: Set<string>,
|
|
193
|
+
input: { tool?: unknown },
|
|
194
|
+
output: { output?: string },
|
|
195
|
+
): void {
|
|
196
|
+
if (!context.gitAutoModeEnabled) return;
|
|
197
|
+
|
|
198
|
+
const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
|
|
199
|
+
const changes = detectNewlyArchivedChanges(archiveDir, archiveSnapshot).map((entry) =>
|
|
200
|
+
entry.slice("YYYY-MM-DD-".length),
|
|
201
|
+
);
|
|
202
|
+
const commandChange =
|
|
203
|
+
String(input.tool ?? "").toLowerCase() === "bash" ? detectCommandArchive(input, output) : null;
|
|
204
|
+
if (commandChange && !nudgedCommandChanges.has(commandChange)) {
|
|
205
|
+
changes.push(commandChange);
|
|
206
|
+
nudgedCommandChanges.add(commandChange);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const uniqueChanges = changes.filter((change, index) => changes.indexOf(change) === index);
|
|
210
|
+
if (uniqueChanges.length === 0) return;
|
|
211
|
+
const nudges = uniqueChanges.map(
|
|
212
|
+
(change) =>
|
|
213
|
+
`ACTION REQUIRED: An openspec change (${change}) was just archived. ` +
|
|
214
|
+
`Immediately invoke the mate-openspec-artifact-finish skill to complete the finish pipeline ` +
|
|
215
|
+
"(invoke the CLI as `mate`, never through a companion-local wrapper path or as `" +
|
|
216
|
+
"$MATE_COMPANION_BIN_PATH/mate`). " +
|
|
217
|
+
"`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
|
|
218
|
+
"do not hand-commit, do not hand-tag, and do not re-apply delta specs manually. " +
|
|
219
|
+
`Follow the skill's guardrails, completing the non-interactive happy path without asking for confirmation.`,
|
|
220
|
+
);
|
|
221
|
+
output.output = [output.output, ...nudges]
|
|
222
|
+
.map((part) => (part ?? "").trim())
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.join("\n\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const REACT_DOCTOR_VERSION = "0.8.1";
|
|
228
|
+
|
|
229
|
+
const REACT_DOCTOR_NON_LINT_FAILURES = [
|
|
230
|
+
/No React dependency found/i,
|
|
231
|
+
/Could not resolve config/i,
|
|
232
|
+
/is not a git repository/i,
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const REACT_DOCTOR_EDIT_TOOLS = new Set(["write", "edit", "apply_patch"]);
|
|
236
|
+
const REACT_DOCTOR_ARGS = [
|
|
237
|
+
"--yes",
|
|
238
|
+
"--verbose",
|
|
239
|
+
"--scope",
|
|
240
|
+
"changed",
|
|
241
|
+
"--base",
|
|
242
|
+
"HEAD",
|
|
243
|
+
"--blocking",
|
|
244
|
+
"warning",
|
|
245
|
+
"--no-score",
|
|
246
|
+
"--max-duration",
|
|
247
|
+
"30",
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
async function runReactDoctorScan(
|
|
251
|
+
context: CompanionContext,
|
|
252
|
+
client: PluginInput["client"],
|
|
253
|
+
$: PluginInput["$"],
|
|
254
|
+
sessionID: string,
|
|
255
|
+
scansInFlight: Set<string>,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const repo = context.repositoryPath;
|
|
258
|
+
if (!repo || scansInFlight.has(sessionID)) return;
|
|
259
|
+
scansInFlight.add(sessionID);
|
|
260
|
+
try {
|
|
261
|
+
const localBin = path.join(repo, "node_modules", ".bin", "react-doctor");
|
|
262
|
+
const mateBin = process.env.MATE_REACT_DOCTOR_BIN_PATH;
|
|
263
|
+
const doctorBin = mateBin && fs.existsSync(mateBin) ? mateBin : localBin;
|
|
264
|
+
|
|
265
|
+
const result = fs.existsSync(doctorBin)
|
|
266
|
+
? await $`${doctorBin} ${REACT_DOCTOR_ARGS}`.cwd(repo).nothrow().quiet()
|
|
267
|
+
: await $`npx --yes ${`react-doctor@${REACT_DOCTOR_VERSION}`} ${REACT_DOCTOR_ARGS}`
|
|
268
|
+
.cwd(repo)
|
|
269
|
+
.nothrow()
|
|
270
|
+
.quiet();
|
|
271
|
+
|
|
272
|
+
if (result.exitCode === 0) return;
|
|
273
|
+
|
|
274
|
+
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`.trim();
|
|
275
|
+
if (!output || REACT_DOCTOR_NON_LINT_FAILURES.some((re) => re.test(output))) return;
|
|
276
|
+
|
|
277
|
+
await client.tui
|
|
278
|
+
.showToast({
|
|
279
|
+
query: { directory: repo },
|
|
280
|
+
body: {
|
|
281
|
+
title: "React Doctor",
|
|
282
|
+
message: "Found issues in the changed files — review before finishing.",
|
|
283
|
+
variant: "warning",
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {});
|
|
287
|
+
await client.session
|
|
288
|
+
.promptAsync({
|
|
289
|
+
path: { id: sessionID },
|
|
290
|
+
query: { directory: repo },
|
|
291
|
+
body: {
|
|
292
|
+
parts: [
|
|
293
|
+
{
|
|
294
|
+
type: "text",
|
|
295
|
+
synthetic: true,
|
|
296
|
+
text:
|
|
297
|
+
"React Doctor found issues in the changed files. Review this output and fix " +
|
|
298
|
+
"the regressions before finishing. For confirmed issues that cannot be fixed " +
|
|
299
|
+
"now, create GitHub issues with the rule, file/line, confidence, impact, and " +
|
|
300
|
+
`proposed fix.\n\n${output}`,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
.catch(() => {});
|
|
306
|
+
} catch {
|
|
307
|
+
// Never break the session because a diagnostic scan failed.
|
|
308
|
+
} finally {
|
|
309
|
+
scansInFlight.delete(sessionID);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type PluginEventInput = Parameters<NonNullable<Hooks["event"]>>[0];
|
|
314
|
+
type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
|
|
315
|
+
type ToolAfterOutput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[1];
|
|
316
|
+
|
|
317
|
+
export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
|
|
318
|
+
const { client, $ } = pluginInput;
|
|
319
|
+
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
320
|
+
if (!context.companionPath || !context.repositoryPath) return {};
|
|
321
|
+
|
|
322
|
+
const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
|
|
323
|
+
const archiveSnapshot = readArchiveEntries(archiveDir);
|
|
324
|
+
const nudgedCommandChanges = new Set<string>();
|
|
325
|
+
const dirtyReactDoctorSessions = new Set<string>();
|
|
326
|
+
const reactDoctorScansInFlight = new Set<string>();
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
event: async ({ event }: PluginEventInput) => {
|
|
330
|
+
if (event.type !== "session.idle") return;
|
|
331
|
+
const sessionID = event.properties.sessionID;
|
|
332
|
+
if (
|
|
333
|
+
!context.reactDoctorEnabled ||
|
|
334
|
+
reactDoctorScansInFlight.has(sessionID) ||
|
|
335
|
+
!dirtyReactDoctorSessions.delete(sessionID)
|
|
336
|
+
) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
|
|
340
|
+
},
|
|
341
|
+
"tool.execute.before": async (
|
|
342
|
+
input: { tool: unknown },
|
|
343
|
+
output: { args: { filePath?: unknown; patchText?: unknown } | undefined },
|
|
344
|
+
) => {
|
|
345
|
+
const toolName = String(input.tool ?? "");
|
|
346
|
+
const args = output.args ?? {};
|
|
347
|
+
if (["write", "edit"].includes(toolName)) {
|
|
348
|
+
const filePath = String(args.filePath ?? "");
|
|
349
|
+
if (filePath && shouldBlockArtifactWrite(context, filePath)) {
|
|
350
|
+
throw new Error(buildArtifactError(context, filePath));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (toolName === "apply_patch") {
|
|
354
|
+
for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
|
|
355
|
+
if (shouldBlockArtifactWrite(context, filePath)) {
|
|
356
|
+
throw new Error(buildArtifactError(context, filePath));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
"tool.execute.after": async (input: ToolAfterInput, output: ToolAfterOutput) => {
|
|
362
|
+
if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
|
|
363
|
+
dirtyReactDoctorSessions.add(input.sessionID);
|
|
364
|
+
}
|
|
365
|
+
appendOpenSpecFinishNudge(context, archiveSnapshot, nudgedCommandChanges, input, output);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export default CompanionHooksPlugin;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { readCompanionRuntimeContext, type CompanionRuntimeContext } from "@uniqbit/mate-core";
|
|
6
|
+
|
|
7
|
+
export type CompanionContext = CompanionRuntimeContext & {
|
|
8
|
+
agentsMd: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const ARTIFACT_BASENAMES = new Set([
|
|
12
|
+
"CLAUDE.md",
|
|
13
|
+
"CONTEXT.md",
|
|
14
|
+
"design.md",
|
|
15
|
+
"explore-brief.md",
|
|
16
|
+
"proposal.md",
|
|
17
|
+
"spec.md",
|
|
18
|
+
"tasks.md",
|
|
19
|
+
]);
|
|
20
|
+
const ARTIFACT_SEGMENTS = [
|
|
21
|
+
"/changes/",
|
|
22
|
+
"/openspec/",
|
|
23
|
+
"/specs/",
|
|
24
|
+
"/docs/adr/",
|
|
25
|
+
"/docs/adrs/",
|
|
26
|
+
"/docs/decisions/",
|
|
27
|
+
"/docs/prd/",
|
|
28
|
+
];
|
|
29
|
+
function normalizedPolicyPath(filePath: string): string {
|
|
30
|
+
return `/${filePath.replace(/\\/g, "/").replace(/^\/+/, "")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isArtifactPath(filePath: string): boolean {
|
|
34
|
+
const basename = path.basename(filePath);
|
|
35
|
+
const ext = path.extname(basename).toLowerCase();
|
|
36
|
+
const normalized = normalizedPolicyPath(filePath);
|
|
37
|
+
|
|
38
|
+
if (ARTIFACT_BASENAMES.has(basename)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (ARTIFACT_SEGMENTS.some((segment) => normalized.includes(segment))) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (ext === ".md" && basename !== "README.md") {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isProductDocumentationPath(context: CompanionContext, filePath: string): boolean {
|
|
54
|
+
const normalized = normalizeTargetPath(context, filePath);
|
|
55
|
+
const relativePath = path.relative(context.repositoryPath, normalized);
|
|
56
|
+
if (!relativePath || relativePath.startsWith("..")) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parts = relativePath.split(path.sep);
|
|
61
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
62
|
+
const part = parts[index];
|
|
63
|
+
if (part === ".storybook" || part === "storybook") {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (part !== "docs") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const docsRoot = path.join(context.repositoryPath, ...parts.slice(0, index + 1));
|
|
72
|
+
if (fs.existsSync(path.join(docsRoot, "package.json"))) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeTargetPath(context: CompanionContext, filePath: string): string {
|
|
81
|
+
if (path.isAbsolute(filePath)) {
|
|
82
|
+
return path.normalize(filePath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return path.normalize(path.join(context.repositoryPath, filePath));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isGitignored(context: CompanionContext, filePath: string): boolean {
|
|
89
|
+
if (!context.repositoryPath) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const normalized = normalizeTargetPath(context, filePath);
|
|
94
|
+
if (!normalized.startsWith(path.normalize(context.repositoryPath))) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const relativePath = path.relative(context.repositoryPath, normalized);
|
|
99
|
+
if (!relativePath || relativePath.startsWith("..")) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = spawnSync(
|
|
104
|
+
"git",
|
|
105
|
+
["-C", context.repositoryPath, "check-ignore", "--no-index", "-q", "--", relativePath],
|
|
106
|
+
{
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
stdio: "ignore",
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return result.status === 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function shouldBlockArtifactWrite(context: CompanionContext, filePath: string): boolean {
|
|
116
|
+
if (!filePath) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (normalizeTargetPath(context, filePath).startsWith(path.normalize(context.companionPath))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isProductDocumentationPath(context, filePath)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isArtifactPath(filePath)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return !isGitignored(context, filePath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const PATCH_FILE_MARKER = /\*\*\* (?:Add|Update|Move to|Delete) File:\s*(.+)$/gm;
|
|
136
|
+
|
|
137
|
+
export function extractPatchPaths(patchText: string): string[] {
|
|
138
|
+
const paths: string[] = [];
|
|
139
|
+
let match;
|
|
140
|
+
while ((match = PATCH_FILE_MARKER.exec(patchText)) !== null) {
|
|
141
|
+
const raw = match[1].trim();
|
|
142
|
+
if (raw) {
|
|
143
|
+
paths.push(raw);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return paths;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function buildArtifactError(context: CompanionContext, filePath: string): string {
|
|
150
|
+
const rel = path.relative(context.repositoryPath, filePath);
|
|
151
|
+
const suggested = path.join(context.companionPath, rel);
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
`${context.frameworkName} guardrail: artifact writes must go to the companion framework path.`,
|
|
155
|
+
` target: ${filePath}`,
|
|
156
|
+
` suggested: ${suggested}`,
|
|
157
|
+
`Rewrite to ${suggested} and retry.`,
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readContext(companionPath: string): CompanionContext {
|
|
162
|
+
const agentsMdPath = path.join(companionPath, "AGENTS.md");
|
|
163
|
+
let agentsMd = "";
|
|
164
|
+
try {
|
|
165
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
166
|
+
agentsMd = fs.readFileSync(agentsMdPath, "utf8");
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore read errors; AGENTS.md is optional
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
...readCompanionRuntimeContext(process.env),
|
|
174
|
+
companionPath,
|
|
175
|
+
agentsMd,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/companion.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
import { tool } from "@opencode-ai/plugin";
|
|
5
|
+
import { MATE_ENV, parseGuidanceContent, type MateGuidanceFile } from "@uniqbit/mate-core";
|
|
6
|
+
|
|
7
|
+
import { readContext, type CompanionContext } from "./companion-policy";
|
|
8
|
+
|
|
9
|
+
function prependPathEntry(pathValue: string | undefined, entry: string): string {
|
|
10
|
+
const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
|
|
11
|
+
return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildStartupError(details: string[]): Error {
|
|
15
|
+
return new Error(
|
|
16
|
+
[
|
|
17
|
+
"Mate OpenCode companion plugin is incomplete and cannot start.",
|
|
18
|
+
...details.map((detail) => `- ${detail}`),
|
|
19
|
+
"Launch through `mate opencode` so the CLI injects the companion guidance.",
|
|
20
|
+
].join("\n"),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadGuidance(): MateGuidanceFile {
|
|
25
|
+
const raw = process.env[MATE_ENV.guidanceJson];
|
|
26
|
+
if (!raw || !raw.trim()) {
|
|
27
|
+
throw buildStartupError([`missing ${MATE_ENV.guidanceJson} in the launch environment`]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { guidance, errors } = parseGuidanceContent(raw);
|
|
31
|
+
if (errors.length > 0) {
|
|
32
|
+
throw buildStartupError(errors);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return guidance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function materializeCompanionGuidance(guidance: string, context: CompanionContext): string {
|
|
39
|
+
const wrapperBinPath = process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
|
|
40
|
+
return guidance
|
|
41
|
+
.replaceAll("$MATE_REPO_PATH", context.repositoryPath)
|
|
42
|
+
.replaceAll("$MATE_ARTIFACT_PATH", context.companionPath)
|
|
43
|
+
.replaceAll("$MATE_WRAPPER_BIN_PATH", wrapperBinPath)
|
|
44
|
+
.replaceAll("$MATE_REPO_ID", context.repositoryId)
|
|
45
|
+
.replaceAll("$MATE_REPO_PROFILE", context.repositoryProfile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile): string[] {
|
|
49
|
+
const lines = [materializeCompanionGuidance(guidance.companionGuidance, context)];
|
|
50
|
+
|
|
51
|
+
if (guidance.codebaseExplorationGuidance.trim()) {
|
|
52
|
+
lines.push("", guidance.codebaseExplorationGuidance);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (context.agentsMd.trim()) {
|
|
56
|
+
lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const CompanionPlugin: Plugin = async () => {
|
|
63
|
+
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
64
|
+
if (!context.companionPath || !context.repositoryPath) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const guidance = loadGuidance();
|
|
69
|
+
|
|
70
|
+
const wrapperBinPath = process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
tool: {
|
|
74
|
+
companion_paths: tool({
|
|
75
|
+
description: `Return active ${context.frameworkName} working repository and companion framework paths.`,
|
|
76
|
+
args: {},
|
|
77
|
+
async execute() {
|
|
78
|
+
return {
|
|
79
|
+
output: JSON.stringify(
|
|
80
|
+
{
|
|
81
|
+
companionFrameworkPath: context.companionPath,
|
|
82
|
+
wrapperBinPath,
|
|
83
|
+
repositoryPath: context.repositoryPath,
|
|
84
|
+
repositoryId: context.repositoryId,
|
|
85
|
+
repositoryProfile: context.repositoryProfile,
|
|
86
|
+
policy: JSON.parse(context.policyJson || "{}"),
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2,
|
|
90
|
+
),
|
|
91
|
+
metadata: {
|
|
92
|
+
companionPath: context.companionPath,
|
|
93
|
+
wrapperBinPath,
|
|
94
|
+
repositoryPath: context.repositoryPath,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
"experimental.chat.system.transform": async (_input: any, output: { system: string[] }) => {
|
|
101
|
+
// Collapse the whole system prompt into a single entry. opencode expands
|
|
102
|
+
// each `system[]` element into its own `role:"system"` wire message, and
|
|
103
|
+
// some self-hosted chat templates (e.g. Qwen served via vLLM) reject any
|
|
104
|
+
// system message that is not the very first message. Merging opencode's
|
|
105
|
+
// own parts with the mate companion block guarantees exactly one leading
|
|
106
|
+
// system message.
|
|
107
|
+
const companion = buildSystemPrompt(context, guidance).join("\n");
|
|
108
|
+
const merged = [...output.system, companion]
|
|
109
|
+
.map((part) => part.trim())
|
|
110
|
+
.filter((part) => part.length > 0)
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
output.system.length = 0;
|
|
113
|
+
if (merged.length > 0) {
|
|
114
|
+
output.system.push(merged);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"experimental.session.compacting": async (_input: any, output: { context: string[] }) => {
|
|
118
|
+
output.context.push(buildSystemPrompt(context, guidance).join("\n"));
|
|
119
|
+
},
|
|
120
|
+
"shell.env": async (_input: any, output: { env: Record<string, string> }) => {
|
|
121
|
+
output.env.MATE_NAME = context.frameworkName;
|
|
122
|
+
output.env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
|
|
123
|
+
output.env.MATE_ARTIFACT_PATH = context.companionPath;
|
|
124
|
+
output.env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
|
|
125
|
+
output.env.MATE_REPO_PATH = context.repositoryPath;
|
|
126
|
+
output.env.MATE_REPO_ID = context.repositoryId;
|
|
127
|
+
output.env.MATE_REPO_PROFILE = context.repositoryProfile;
|
|
128
|
+
output.env.MATE_POLICY_JSON = context.policyJson;
|
|
129
|
+
output.env.MATE_GRAPHIFY_ENABLED = context.graphifyEnabled ? "1" : "0";
|
|
130
|
+
output.env.MATE_GIT_AUTO_MODE = context.gitAutoModeEnabled ? "1" : "0";
|
|
131
|
+
// Session-scoped payload consumed above at plugin startup: mask it so
|
|
132
|
+
// the multi-KB guidance JSON never leaks into spawned shells (and a
|
|
133
|
+
// nested `mate` launch can never inherit a stale copy).
|
|
134
|
+
output.env[MATE_ENV.guidanceJson] = "";
|
|
135
|
+
|
|
136
|
+
output.env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default CompanionPlugin;
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop -- modules must initialize in their fixed execution order
|
|
2
|
+
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
import { AddDirPlugin } from "./add-dir";
|
|
5
|
+
import { CompanionHooksPlugin } from "./companion-hooks";
|
|
6
|
+
import { CompanionPlugin } from "./companion";
|
|
7
|
+
|
|
8
|
+
// Compose the implementation modules in the execution order the previously
|
|
9
|
+
// copied plugin files were discovered in (alphabetical file order:
|
|
10
|
+
// mate-add-dir, mate-companion-hooks, mate-companion).
|
|
11
|
+
const MODULES: Plugin[] = [AddDirPlugin, CompanionHooksPlugin, CompanionPlugin];
|
|
12
|
+
|
|
13
|
+
type HookValue = Hooks[keyof Hooks];
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function chainHooks(first: HookValue, second: HookValue): HookValue {
|
|
20
|
+
if (typeof first === "function" && typeof second === "function") {
|
|
21
|
+
return (async (...args: unknown[]) => {
|
|
22
|
+
await (first as (...values: unknown[]) => Promise<unknown>)(...args);
|
|
23
|
+
await (second as (...values: unknown[]) => Promise<unknown>)(...args);
|
|
24
|
+
}) as HookValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (isRecord(first) && isRecord(second)) {
|
|
28
|
+
return { ...first, ...second } as HookValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return second;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Aggregate regular Mate OpenCode plugin. Loaded through the package's
|
|
36
|
+
* `./server` export; stays inert when the session is not Mate-managed
|
|
37
|
+
* because every composed module checks the Mate launch environment itself.
|
|
38
|
+
*/
|
|
39
|
+
export const MateOpenCodePlugin: Plugin = async (input: PluginInput) => {
|
|
40
|
+
const merged: Record<string, HookValue> = {};
|
|
41
|
+
|
|
42
|
+
for (const module of MODULES) {
|
|
43
|
+
const hooks = await module(input);
|
|
44
|
+
for (const [name, value] of Object.entries(hooks) as Array<[string, HookValue]>) {
|
|
45
|
+
if (value === undefined) continue;
|
|
46
|
+
const existing = merged[name];
|
|
47
|
+
merged[name] = existing === undefined ? value : chainHooks(existing, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return merged as Hooks;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default MateOpenCodePlugin;
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope */
|
|
3
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
4
|
+
|
|
5
|
+
import { readContext } from "./companion-policy";
|
|
6
|
+
|
|
7
|
+
const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
|
|
8
|
+
const NARROW_TERMINAL_WIDTH = 80;
|
|
9
|
+
const MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
|
|
10
|
+
|
|
11
|
+
function SessionContext({
|
|
12
|
+
api,
|
|
13
|
+
compact = false,
|
|
14
|
+
sidebar = false,
|
|
15
|
+
}: {
|
|
16
|
+
api: TuiPluginApi;
|
|
17
|
+
compact?: boolean;
|
|
18
|
+
sidebar?: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
21
|
+
const theme = api.theme.current;
|
|
22
|
+
|
|
23
|
+
if (compact) {
|
|
24
|
+
return (
|
|
25
|
+
<box width="100%" paddingTop={0} flexShrink={0}>
|
|
26
|
+
<text fg={theme.textMuted}>
|
|
27
|
+
mate v{MATE_VERSION} | repo: {context.repositoryPath} | mate: {context.companionPath}
|
|
28
|
+
</text>
|
|
29
|
+
</box>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<box width="100%" maxWidth={75} paddingTop={sidebar ? 0 : 2} paddingBottom={1} flexShrink={0}>
|
|
35
|
+
<text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
|
|
36
|
+
<text fg={theme.textMuted}>repo: {context.repositoryPath}</text>
|
|
37
|
+
<text fg={theme.textMuted}>mate: {context.companionPath}</text>
|
|
38
|
+
</box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tui: TuiPlugin = async (api) => {
|
|
43
|
+
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
44
|
+
if (!context.companionPath || !context.repositoryPath) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
api.slots.register({
|
|
49
|
+
order: 0,
|
|
50
|
+
slots: {
|
|
51
|
+
home_bottom() {
|
|
52
|
+
return <SessionContext api={api} />;
|
|
53
|
+
},
|
|
54
|
+
sidebar_content() {
|
|
55
|
+
return <SessionContext api={api} sidebar />;
|
|
56
|
+
},
|
|
57
|
+
app_bottom() {
|
|
58
|
+
if (api.renderer.width >= NARROW_TERMINAL_WIDTH || api.route.current.name !== "session") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return <SessionContext api={api} compact />;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
69
|
+
id: "mate-companion-tui",
|
|
70
|
+
tui,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default plugin;
|