@uniqbit/mate-opencode-plugin 0.17.0-canary.8 → 0.17.1

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 CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-opencode-plugin",
3
- "version": "0.17.0-canary.8",
3
+ "version": "0.17.1",
4
4
  "description": "OpenCode plugin integration for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
7
+ "server.ts",
8
+ "tui.tsx",
7
9
  "src/",
8
10
  "!src/**/*.test.ts"
9
11
  ],
@@ -24,15 +26,30 @@
24
26
  "format:check": "oxfmt src/ --check"
25
27
  },
26
28
  "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.17.0-canary.8"
29
+ "@opencode/plugin": "^2.0.23",
30
+ "@uniqbit/mate-core": "0.17.1"
32
31
  },
33
32
  "devDependencies": {
34
- "@types/bun": "^1.3.14",
35
- "context-mode": "1.0.169"
33
+ "@opentui/core": "^0.5.14",
34
+ "@opentui/keymap": "^0.5.14",
35
+ "@opentui/solid": "^0.5.14",
36
+ "@types/bun": "^1.3.14"
37
+ },
38
+ "peerDependencies": {
39
+ "@opentui/core": ">=0.5.14",
40
+ "@opentui/keymap": ">=0.5.14",
41
+ "@opentui/solid": ">=0.5.14"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@opentui/core": {
45
+ "optional": true
46
+ },
47
+ "@opentui/keymap": {
48
+ "optional": true
49
+ },
50
+ "@opentui/solid": {
51
+ "optional": true
52
+ }
36
53
  },
37
54
  "engines": {
38
55
  "node": ">=24"
package/server.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Root entry for a plugin bound by directory path: OpenCode resolves `<dir>/server`
3
+ * there and never reads `exports`, which serves only package-name references.
4
+ */
5
+ export { default } from "./src/server";
package/src/add-dir.ts CHANGED
@@ -1,17 +1,15 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
 
3
- import type { Config, Hooks, Plugin } from "@opencode-ai/plugin";
4
- import { readCompanionRuntimeContext } from "@uniqbit/mate-core/runtime";
4
+ import type { Context } from "@opencode/plugin/promise/plugin";
5
5
 
6
- // OpenCode accepts a per-path rule map for external_directory at runtime even
7
- // though the published Config type narrows it to a single rule value.
8
- type ExternalDirectoryRules = Record<string, "allow" | "ask" | "deny" | undefined>;
9
- type ConfigHook = NonNullable<Hooks["config"]>;
10
- type SkillConfig = { skills?: { paths?: string[] } };
11
-
12
- function companionPathRules(companionPath: string): string[] {
13
- return [companionPath, `${companionPath}/**`];
14
- }
6
+ type CompanionSkill = {
7
+ id: string;
8
+ name: string;
9
+ description?: string;
10
+ path: string;
11
+ content: string;
12
+ };
15
13
 
16
14
  function companionSkillPaths(companionPath: string): string[] {
17
15
  return [
@@ -20,50 +18,101 @@ function companionSkillPaths(companionPath: string): string[] {
20
18
  ];
21
19
  }
22
20
 
23
- function externalDirectoryRules(config: Config): ExternalDirectoryRules {
24
- const permission = (config.permission ??= {}) as { external_directory?: unknown };
25
- permission.external_directory ??= {};
26
- return permission.external_directory as ExternalDirectoryRules;
21
+ function isWithin(root: string, candidate: string): boolean {
22
+ const relative = path.relative(root, path.resolve(candidate));
23
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
27
24
  }
28
25
 
29
- function skillPaths(config: Config): string[] {
30
- const skillConfig = config as Config & SkillConfig;
31
- skillConfig.skills ??= {};
32
- skillConfig.skills.paths ??= [];
33
- return skillConfig.skills.paths;
26
+ function parseFrontmatter(content: string): { name?: string; description?: string } {
27
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
28
+ if (!match) return {};
29
+
30
+ const scalar = (key: string) => {
31
+ const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
32
+ return value?.replace(
33
+ /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
34
+ (_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
35
+ );
36
+ };
37
+
38
+ return { name: scalar("name"), description: scalar("description") };
34
39
  }
35
40
 
36
- /**
37
- * Resolves through the composed reader — launch environment first, Projection
38
- * Root second — so a wrapped Working Repository allow-lists its companion in a
39
- * session Mate did not start. No variable name is spelled here.
40
- */
41
- export const AddDirPlugin: Plugin = async () => {
42
- const companionPath = readCompanionRuntimeContext().companionPath.trim();
43
- if (!companionPath) {
44
- return {};
41
+ async function findSkillFiles(directory: string): Promise<string[]> {
42
+ try {
43
+ const entries = await fs.readdir(directory, { withFileTypes: true });
44
+ const files = entries
45
+ .filter((entry) => entry.isFile() && entry.name === "SKILL.md")
46
+ .map((entry) => path.join(directory, entry.name));
47
+ const nested = await Promise.all(
48
+ entries
49
+ .filter((entry) => entry.isDirectory())
50
+ .map((entry) => findSkillFiles(path.join(directory, entry.name))),
51
+ );
52
+ return [...files, ...nested.flat()];
53
+ } catch {
54
+ return [];
45
55
  }
56
+ }
46
57
 
47
- const resolvedCompanionPath = path.resolve(companionPath);
48
-
49
- return {
50
- config: (async (cfg) => {
51
- const externalDirectory = externalDirectoryRules(cfg);
58
+ function skillID(name: string): string {
59
+ return (
60
+ name
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9_-]+/g, "-")
63
+ .replace(/^-+|-+$/g, "") || "mate-skill"
64
+ );
65
+ }
52
66
 
53
- for (const rule of companionPathRules(resolvedCompanionPath)) {
54
- if (externalDirectory[rule] === undefined) {
55
- externalDirectory[rule] = "allow";
56
- }
67
+ async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
68
+ const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
69
+ const skills = await Promise.all(
70
+ files.map(async (filePath): Promise<CompanionSkill | null> => {
71
+ try {
72
+ const content = await fs.readFile(filePath, "utf8");
73
+ const metadata = parseFrontmatter(content);
74
+ const name = metadata.name || path.basename(path.dirname(filePath));
75
+ return {
76
+ id: skillID(name),
77
+ name,
78
+ ...(metadata.description ? { description: metadata.description } : {}),
79
+ path: filePath,
80
+ content,
81
+ };
82
+ } catch {
83
+ /** An unreadable companion skill must not prevent the session from loading. */
84
+ return null;
57
85
  }
86
+ }),
87
+ );
88
+ return skills.filter((skill) => skill !== null);
89
+ }
58
90
 
59
- const paths = skillPaths(cfg);
60
- for (const skillPath of companionSkillPaths(resolvedCompanionPath)) {
61
- if (!paths.includes(skillPath)) {
62
- paths.push(skillPath);
63
- }
64
- }
65
- }) satisfies ConfigHook,
66
- };
67
- };
91
+ /**
92
+ * Grants the session access to the companion and registers its skills. The
93
+ * permission hook only widens access for companion resources; every other
94
+ * evaluation keeps OpenCode's own verdict.
95
+ */
96
+ export async function registerCompanionAccess(api: Context, companionPath: string): Promise<void> {
97
+ const permissionRoot = path.resolve(companionPath);
98
+
99
+ await api.permission.hook("evaluate", (event) => {
100
+ if (event.resources.some((resource) => isWithin(permissionRoot, resource))) {
101
+ event.effect = "allow";
102
+ delete event.message;
103
+ }
104
+ });
105
+
106
+ const skills = await loadCompanionSkills(permissionRoot);
107
+ if (skills.length === 0) return;
68
108
 
69
- export default AddDirPlugin;
109
+ await api.skill.transform((editor) => {
110
+ for (const skill of skills) {
111
+ if (editor.get(skill.id)) {
112
+ editor.update(skill.id, (current) => Object.assign(current, skill));
113
+ } else {
114
+ editor.add(skill as never);
115
+ }
116
+ }
117
+ });
118
+ }
package/src/companion.ts CHANGED
@@ -1,24 +1,23 @@
1
1
  import path from "node:path";
2
2
 
3
- import type { Plugin } from "@opencode-ai/plugin";
4
- import { tool } from "@opencode-ai/plugin";
3
+ import type { Context } from "@opencode/plugin/promise/plugin";
5
4
  import {
6
5
  COMPANION_POLICY_MARKER,
7
6
  MATE_ENV,
8
7
  type MateGuidanceFile,
9
8
  } from "@uniqbit/mate-core/runtime";
10
9
 
11
- import {
12
- readContext,
13
- resolveOpenCodeGuidance,
14
- type CompanionContext,
15
- } from "@uniqbit/mate-core/opencode";
10
+ import { resolveOpenCodeGuidance, type CompanionContext } from "@uniqbit/mate-core/opencode";
16
11
 
17
12
  function prependPathEntry(pathValue: string | undefined, entry: string): string {
18
13
  const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
19
14
  return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
20
15
  }
21
16
 
17
+ function resolveWrapperBinPath(): string {
18
+ return process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
19
+ }
20
+
22
21
  function buildStartupError(details: string[]): Error {
23
22
  return new Error(
24
23
  [
@@ -34,14 +33,14 @@ function buildStartupError(details: string[]): Error {
34
33
  * an Unmanaged Session in a wrapped repository builds the same payload from the
35
34
  * companion the projection names, with the Capability names read live.
36
35
  */
37
- function loadGuidance(): MateGuidanceFile | null {
38
- const { guidance, errors } = resolveOpenCodeGuidance();
36
+ function loadGuidance(directory: string): MateGuidanceFile | null {
37
+ const { guidance, errors } = resolveOpenCodeGuidance(process.env, directory);
39
38
  if (errors.length > 0) throw buildStartupError(errors);
40
39
  return guidance;
41
40
  }
42
41
 
43
42
  function materializeCompanionGuidance(guidance: string, context: CompanionContext): string {
44
- const wrapperBinPath = process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
43
+ const wrapperBinPath = resolveWrapperBinPath();
45
44
  return guidance
46
45
  .replaceAll("$MATE_REPO_PATH", context.repositoryPath)
47
46
  .replaceAll("$MATE_ARTIFACT_PATH", context.companionPath)
@@ -63,95 +62,117 @@ function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile
63
62
  return lines;
64
63
  }
65
64
 
66
- export const CompanionPlugin: Plugin = async () => {
67
- const context = readContext();
68
- if (!context.companionPath) {
69
- return {};
70
- }
71
-
72
- const guidance = loadGuidance();
73
- if (!guidance) return {};
65
+ function readSystemText(part: unknown): string {
66
+ if (typeof part === "string") return part;
67
+ if (typeof part !== "object" || part === null || !("text" in part)) return "";
68
+ return typeof part.text === "string" ? part.text : "";
69
+ }
74
70
 
75
- const wrapperBinPath = process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
71
+ /** Session-scoped variables a spawned shell sees; mutates `env` in place. */
72
+ function applyCompanionShellEnv(
73
+ context: CompanionContext,
74
+ env: Record<string, string | undefined>,
75
+ ): void {
76
+ const wrapperBinPath = resolveWrapperBinPath();
77
+ env.MATE_NAME = context.frameworkName;
78
+ env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
79
+ env.MATE_ARTIFACT_PATH = context.companionPath;
80
+ env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
81
+ if (context.repositoryPath) {
82
+ env.MATE_REPO_PATH = context.repositoryPath;
83
+ env.MATE_REPO_ID = context.repositoryId;
84
+ } else {
85
+ delete env.MATE_REPO_PATH;
86
+ delete env.MATE_REPO_ID;
87
+ }
88
+ env.MATE_POLICY_JSON = context.policyJson;
89
+ env.MATE_GRAPHIFY_ENABLED = context.graphifyEnabled ? "1" : "0";
90
+ env.MATE_GIT_AUTO_MODE = context.gitAutoModeEnabled ? "1" : "0";
91
+ /**
92
+ * Session-scoped payload consumed at plugin startup: masked so the multi-KB
93
+ * guidance JSON never leaks into spawned shells (and a nested `mate` launch
94
+ * can never inherit a stale copy).
95
+ */
96
+ env[MATE_ENV.guidanceJson] = "";
97
+ env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
98
+ }
76
99
 
100
+ function companionPathsResult(context: CompanionContext) {
101
+ const wrapperBinPath = resolveWrapperBinPath();
77
102
  return {
78
- tool: {
79
- companion_paths: tool({
80
- description: `Return active ${context.frameworkName} companion framework and working repository paths.`,
81
- args: {},
82
- async execute() {
83
- return {
84
- output: JSON.stringify(
85
- {
86
- companionFrameworkPath: context.companionPath,
87
- wrapperBinPath,
88
- ...(context.repositoryPath
89
- ? { repositoryPath: context.repositoryPath, repositoryId: context.repositoryId }
90
- : {}),
91
- policy: JSON.parse(context.policyJson || "{}"),
92
- },
93
- null,
94
- 2,
95
- ),
96
- metadata: {
97
- companionPath: context.companionPath,
98
- wrapperBinPath,
99
- ...(context.repositoryPath ? { repositoryPath: context.repositoryPath } : {}),
100
- },
101
- };
102
- },
103
- }),
104
- },
105
- "experimental.chat.system.transform": async (_input: any, output: { system: string[] }) => {
106
- // A wrapped repository lists this plugin in its own project config, so a
107
- // managed launch — whose OPENCODE_CONFIG_DIR names the companion, which
108
- // lists it too — loads it twice. The marker is what makes the second load
109
- // inert instead of appending the companion block a second time.
110
- if (output.system.some((part) => part.includes(COMPANION_POLICY_MARKER))) return;
111
-
112
- // Collapse the whole system prompt into a single entry. opencode expands
113
- // each `system[]` element into its own `role:"system"` wire message, and
114
- // some self-hosted chat templates (e.g. Qwen served via vLLM) reject any
115
- // system message that is not the very first message. Merging opencode's
116
- // own parts with the mate companion block guarantees exactly one leading
117
- // system message.
118
- const companion = buildSystemPrompt(context, guidance).join("\n");
119
- const merged = [...output.system, companion]
120
- .map((part) => part.trim())
121
- .filter((part) => part.length > 0)
122
- .join("\n\n");
123
- output.system.length = 0;
124
- if (merged.length > 0) {
125
- output.system.push(merged);
126
- }
127
- },
128
- "experimental.session.compacting": async (_input: any, output: { context: string[] }) => {
129
- if (output.context.some((part) => part.includes(COMPANION_POLICY_MARKER))) return;
130
- output.context.push(buildSystemPrompt(context, guidance).join("\n"));
131
- },
132
- "shell.env": async (_input: any, output: { env: Record<string, string> }) => {
133
- output.env.MATE_NAME = context.frameworkName;
134
- output.env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
135
- output.env.MATE_ARTIFACT_PATH = context.companionPath;
136
- output.env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
137
- if (context.repositoryPath) {
138
- output.env.MATE_REPO_PATH = context.repositoryPath;
139
- output.env.MATE_REPO_ID = context.repositoryId;
140
- } else {
141
- delete output.env.MATE_REPO_PATH;
142
- delete output.env.MATE_REPO_ID;
143
- }
144
- output.env.MATE_POLICY_JSON = context.policyJson;
145
- output.env.MATE_GRAPHIFY_ENABLED = context.graphifyEnabled ? "1" : "0";
146
- output.env.MATE_GIT_AUTO_MODE = context.gitAutoModeEnabled ? "1" : "0";
147
- // Session-scoped payload consumed above at plugin startup: mask it so
148
- // the multi-KB guidance JSON never leaks into spawned shells (and a
149
- // nested `mate` launch can never inherit a stale copy).
150
- output.env[MATE_ENV.guidanceJson] = "";
151
-
152
- output.env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
103
+ content: JSON.stringify(
104
+ {
105
+ companionFrameworkPath: context.companionPath,
106
+ wrapperBinPath,
107
+ ...(context.repositoryPath
108
+ ? { repositoryPath: context.repositoryPath, repositoryId: context.repositoryId }
109
+ : {}),
110
+ policy: JSON.parse(context.policyJson || "{}"),
111
+ },
112
+ null,
113
+ 2,
114
+ ),
115
+ metadata: {
116
+ companionPath: context.companionPath,
117
+ wrapperBinPath,
118
+ ...(context.repositoryPath ? { repositoryPath: context.repositoryPath } : {}),
153
119
  },
154
120
  };
155
- };
121
+ }
122
+
123
+ /**
124
+ * Collapses the whole system prompt into a single entry. OpenCode sends each
125
+ * `system[]` element as its own system message, and some self-hosted chat
126
+ * templates (e.g. Qwen served via vLLM) reject any system message that is not
127
+ * the very first one.
128
+ */
129
+ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
130
+ const texts = system.map(readSystemText);
131
+ /**
132
+ * A wrapped repository lists this plugin in its own project config, so a
133
+ * managed launch loads it twice; the marker keeps the second load inert.
134
+ */
135
+ if (texts.some((text) => text.includes(COMPANION_POLICY_MARKER))) return texts;
136
+ const merged = [...texts, companion]
137
+ .map((part) => part.trim())
138
+ .filter((part) => part.length > 0)
139
+ .join("\n\n");
140
+ return merged.length > 0 ? [merged] : [];
141
+ }
156
142
 
157
- export default CompanionPlugin;
143
+ /** Registers guidance, compaction context, shell env, and `companion_paths`. */
144
+ export async function registerCompanion(api: Context, context: CompanionContext): Promise<void> {
145
+ const guidance = loadGuidance(api.location.directory);
146
+ if (!guidance) return;
147
+
148
+ const companion = buildSystemPrompt(context, guidance).join("\n");
149
+
150
+ await api.session.hook("context", (event) => {
151
+ const merged = mergeCompanionSystem(event.system, companion);
152
+ event.system.splice(
153
+ 0,
154
+ event.system.length,
155
+ ...merged.map((text) => ({ type: "text" as const, text }) as never),
156
+ );
157
+ });
158
+
159
+ await api.session.hook("compaction", (event) => {
160
+ if (event.system.some((part) => readSystemText(part).includes(COMPANION_POLICY_MARKER))) return;
161
+ event.system.push({ type: "text", text: companion } as never);
162
+ });
163
+
164
+ await api.shell.hook("create.before", (event) => {
165
+ applyCompanionShellEnv(context, event.env);
166
+ });
167
+
168
+ await api.tool.transform((editor) => {
169
+ editor.add({
170
+ name: "companion_paths",
171
+ description: `Return active ${context.frameworkName} companion framework and working repository paths.`,
172
+ input: { type: "object", properties: {}, additionalProperties: false },
173
+ async execute() {
174
+ return companionPathsResult(context);
175
+ },
176
+ });
177
+ });
178
+ }
package/src/server.ts CHANGED
@@ -1,54 +1,25 @@
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";
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { readContext, registerCompanionHooks } from "@uniqbit/mate-core/opencode";
3
3
 
4
- import { AddDirPlugin } from "./add-dir";
5
- import { CompanionHooksPlugin } from "@uniqbit/mate-core/opencode";
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
- }
4
+ import { registerCompanionAccess } from "./add-dir";
5
+ import { registerCompanion } from "./companion";
33
6
 
34
7
  /**
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.
8
+ * Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
9
+ * `./server` export; stays inert when no companion resolves from the launch
10
+ * environment or a Projection Root above the session directory.
38
11
  */
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
- };
12
+ export const MateOpenCodePlugin = Plugin.define({
13
+ id: "mate-opencode-plugin",
14
+ async setup(api) {
15
+ const context = readContext(process.env, api.location.directory);
16
+ if (!context.companionPath) return;
17
+
18
+ await registerCompanionAccess(api, context.companionPath);
19
+ const cleanup = await registerCompanionHooks(api, context);
20
+ await registerCompanion(api, context);
21
+ return cleanup;
22
+ },
23
+ });
53
24
 
54
25
  export default MateOpenCodePlugin;
package/src/tui.tsx CHANGED
@@ -1 +1 @@
1
- export { tuiPlugin as default } from "@uniqbit/mate-core/opencode";
1
+ export { default } from "@uniqbit/mate-core/opencode/tui";
package/tui.tsx ADDED
@@ -0,0 +1,2 @@
1
+ /** Root entry for a plugin bound by directory path; see `server.ts`. */
2
+ export { default } from "./src/tui";