opencode-rules-md 0.6.5
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 +21 -0
- package/README.md +650 -0
- package/dist/cli.mjs +374 -0
- package/dist/src/active-rules-state.d.ts +12 -0
- package/dist/src/active-rules-state.d.ts.map +1 -0
- package/dist/src/active-rules-state.js +118 -0
- package/dist/src/active-rules-state.js.map +1 -0
- package/dist/src/cli/config.d.ts +62 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +246 -0
- package/dist/src/cli/config.js.map +1 -0
- package/dist/src/cli/install.d.ts +21 -0
- package/dist/src/cli/install.d.ts.map +1 -0
- package/dist/src/cli/install.js +63 -0
- package/dist/src/cli/install.js.map +1 -0
- package/dist/src/cli/main.d.ts +19 -0
- package/dist/src/cli/main.d.ts.map +1 -0
- package/dist/src/cli/main.js +135 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/real-fs.d.ts +26 -0
- package/dist/src/cli/real-fs.d.ts.map +1 -0
- package/dist/src/cli/real-fs.js +42 -0
- package/dist/src/cli/real-fs.js.map +1 -0
- package/dist/src/cli/status.d.ts +19 -0
- package/dist/src/cli/status.d.ts.map +1 -0
- package/dist/src/cli/status.js +58 -0
- package/dist/src/cli/status.js.map +1 -0
- package/dist/src/debug.d.ts +3 -0
- package/dist/src/debug.d.ts.map +1 -0
- package/dist/src/debug.js +8 -0
- package/dist/src/debug.js.map +1 -0
- package/dist/src/git-branch.d.ts +2 -0
- package/dist/src/git-branch.d.ts.map +1 -0
- package/dist/src/git-branch.js +30 -0
- package/dist/src/git-branch.js.map +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +55 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp-tools.d.ts +8 -0
- package/dist/src/mcp-tools.d.ts.map +1 -0
- package/dist/src/mcp-tools.js +18 -0
- package/dist/src/mcp-tools.js.map +1 -0
- package/dist/src/message-context.d.ts +55 -0
- package/dist/src/message-context.d.ts.map +1 -0
- package/dist/src/message-context.js +110 -0
- package/dist/src/message-context.js.map +1 -0
- package/dist/src/message-paths.d.ts +34 -0
- package/dist/src/message-paths.d.ts.map +1 -0
- package/dist/src/message-paths.js +112 -0
- package/dist/src/message-paths.js.map +1 -0
- package/dist/src/project-fingerprint.d.ts +2 -0
- package/dist/src/project-fingerprint.d.ts.map +1 -0
- package/dist/src/project-fingerprint.js +61 -0
- package/dist/src/project-fingerprint.js.map +1 -0
- package/dist/src/rule-discovery.d.ts +49 -0
- package/dist/src/rule-discovery.d.ts.map +1 -0
- package/dist/src/rule-discovery.js +143 -0
- package/dist/src/rule-discovery.js.map +1 -0
- package/dist/src/rule-filter.d.ts +61 -0
- package/dist/src/rule-filter.d.ts.map +1 -0
- package/dist/src/rule-filter.js +180 -0
- package/dist/src/rule-filter.js.map +1 -0
- package/dist/src/rule-metadata.d.ts +29 -0
- package/dist/src/rule-metadata.d.ts.map +1 -0
- package/dist/src/rule-metadata.js +100 -0
- package/dist/src/rule-metadata.js.map +1 -0
- package/dist/src/runtime-chat.d.ts +34 -0
- package/dist/src/runtime-chat.d.ts.map +1 -0
- package/dist/src/runtime-chat.js +39 -0
- package/dist/src/runtime-chat.js.map +1 -0
- package/dist/src/runtime-context.d.ts +25 -0
- package/dist/src/runtime-context.d.ts.map +1 -0
- package/dist/src/runtime-context.js +109 -0
- package/dist/src/runtime-context.js.map +1 -0
- package/dist/src/runtime.d.ts +31 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +238 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/session-store.d.ts +34 -0
- package/dist/src/session-store.d.ts.map +1 -0
- package/dist/src/session-store.js +90 -0
- package/dist/src/session-store.js.map +1 -0
- package/dist/src/utils.d.ts +15 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +19 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/tui/data/rules.d.ts +63 -0
- package/dist/tui/data/rules.d.ts.map +1 -0
- package/dist/tui/data/rules.js +173 -0
- package/dist/tui/data/rules.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +13 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/slots/sidebar-content.d.ts +11 -0
- package/dist/tui/slots/sidebar-content.d.ts.map +1 -0
- package/dist/tui/slots/sidebar-content.js +150 -0
- package/dist/tui/slots/sidebar-content.js.map +1 -0
- package/package.json +101 -0
- package/src/active-rules-state.ts +166 -0
- package/src/cli/config.ts +312 -0
- package/src/cli/install.ts +94 -0
- package/src/cli/main.ts +167 -0
- package/src/cli/real-fs.ts +65 -0
- package/src/cli/status.ts +80 -0
- package/src/debug.ts +9 -0
- package/src/git-branch.ts +37 -0
- package/src/index.ts +68 -0
- package/src/mcp-tools.ts +24 -0
- package/src/message-context.ts +139 -0
- package/src/message-paths.ts +153 -0
- package/src/project-fingerprint.ts +72 -0
- package/src/rule-discovery.ts +205 -0
- package/src/rule-filter.ts +269 -0
- package/src/rule-metadata.ts +161 -0
- package/src/runtime-chat.ts +68 -0
- package/src/runtime-context.ts +136 -0
- package/src/runtime.ts +369 -0
- package/src/session-store.ts +129 -0
- package/src/utils.ts +36 -0
- package/tui/.atl/.skill-registry.cache.json +3 -0
- package/tui/.atl/skill-registry.md +57 -0
- package/tui/data/rules.ts +230 -0
- package/tui/index.tsx +23 -0
- package/tui/slots/sidebar-content.tsx +364 -0
- package/tui/types/opencode-plugin-tui.d.ts +78 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// tui/data/rules.ts
|
|
2
|
+
import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery';
|
|
3
|
+
import type { RuleMetadata } from '../../src/rule-metadata';
|
|
4
|
+
import { readActiveRulesState } from '../../src/active-rules-state';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/** Represents a rule as displayed in the sidebar */
|
|
8
|
+
export interface SidebarRuleEntry {
|
|
9
|
+
/** Display name (filename stem, disambiguated if needed) */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Relative file path from the rules directory root */
|
|
12
|
+
path: string;
|
|
13
|
+
/** Whether this rule came from global or project-local rules dir */
|
|
14
|
+
source: 'global' | 'project';
|
|
15
|
+
/** Whether the rule has any conditional metadata */
|
|
16
|
+
isConditional: boolean;
|
|
17
|
+
/** Human-readable condition summary */
|
|
18
|
+
conditionSummary: string;
|
|
19
|
+
/** Full metadata for expanded view */
|
|
20
|
+
metadata: RuleMetadata;
|
|
21
|
+
/**
|
|
22
|
+
* Active state of the rule.
|
|
23
|
+
* - true: rule is active (matched by evaluation or unconditional without state file)
|
|
24
|
+
* - false: rule is not active (not matched by evaluation)
|
|
25
|
+
* - null: state not yet determined (conditional rule without state file)
|
|
26
|
+
*/
|
|
27
|
+
isActive: boolean | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoadSidebarRulesResult {
|
|
31
|
+
rules: SidebarRuleEntry[];
|
|
32
|
+
skippedCount: number;
|
|
33
|
+
/** Whether active rules state was successfully read from disk */
|
|
34
|
+
hasEvaluationState: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load all discovered rules formatted for sidebar display.
|
|
39
|
+
* Reuses discoverRuleFiles/getCachedRule from the server plugin.
|
|
40
|
+
*
|
|
41
|
+
* @param projectDir - Project directory or null (global rules only)
|
|
42
|
+
* @param sessionId - Optional session ID to read active rules state
|
|
43
|
+
*/
|
|
44
|
+
export async function loadSidebarRules(
|
|
45
|
+
projectDir: string | null,
|
|
46
|
+
sessionId?: string
|
|
47
|
+
): Promise<LoadSidebarRulesResult> {
|
|
48
|
+
// discoverRuleFiles accepts string | undefined, not null
|
|
49
|
+
const discovered = await discoverRuleFiles(projectDir ?? undefined);
|
|
50
|
+
|
|
51
|
+
// Read active rules state if sessionId provided
|
|
52
|
+
const activeState = sessionId ? await readActiveRulesState(sessionId) : null;
|
|
53
|
+
const hasEvaluationState = activeState !== null;
|
|
54
|
+
const matchedPathsSet = hasEvaluationState
|
|
55
|
+
? new Set(activeState.matchedRulePaths)
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
const entries: SidebarRuleEntry[] = [];
|
|
59
|
+
let skippedCount = 0;
|
|
60
|
+
|
|
61
|
+
for (const rule of discovered) {
|
|
62
|
+
const cached = await getCachedRule(rule.filePath);
|
|
63
|
+
if (!cached) {
|
|
64
|
+
// getCachedRule() already logs a warning for read failures,
|
|
65
|
+
// so we only increment the counter here — no duplicate log.
|
|
66
|
+
skippedCount++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const meta = cached.metadata;
|
|
71
|
+
const source = ruleSource(rule.filePath, projectDir);
|
|
72
|
+
const isConditional = hasConditions(meta);
|
|
73
|
+
const conditionSummary = isConditional
|
|
74
|
+
? formatConditionSummary(meta!)
|
|
75
|
+
: 'always active';
|
|
76
|
+
|
|
77
|
+
// Determine isActive based on state file or fallback logic
|
|
78
|
+
let isActive: boolean | null;
|
|
79
|
+
if (matchedPathsSet !== null) {
|
|
80
|
+
// With state file: check if this rule's absolute path is in matchedPaths
|
|
81
|
+
isActive = matchedPathsSet.has(rule.filePath);
|
|
82
|
+
} else {
|
|
83
|
+
// Without state file: unconditional = true, conditional = null
|
|
84
|
+
isActive = isConditional ? null : true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
entries.push({
|
|
88
|
+
name: '', // placeholder — set in disambiguation pass
|
|
89
|
+
path: rule.relativePath,
|
|
90
|
+
source,
|
|
91
|
+
isConditional,
|
|
92
|
+
conditionSummary,
|
|
93
|
+
metadata: meta ?? {},
|
|
94
|
+
isActive,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
disambiguateNames(entries);
|
|
99
|
+
|
|
100
|
+
// Sort: project first, then global. Active rules to top, then alpha by name.
|
|
101
|
+
const activeOrder = (v: boolean | null): number =>
|
|
102
|
+
v === true ? 0 : v === null ? 1 : 2;
|
|
103
|
+
entries.sort((a, b) => {
|
|
104
|
+
if (a.source !== b.source) return a.source === 'project' ? -1 : 1;
|
|
105
|
+
const activeCmp = activeOrder(a.isActive) - activeOrder(b.isActive);
|
|
106
|
+
if (activeCmp !== 0) return activeCmp;
|
|
107
|
+
const nameCompare = a.name.localeCompare(b.name);
|
|
108
|
+
if (nameCompare !== 0) return nameCompare;
|
|
109
|
+
return a.path.localeCompare(b.path);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return { rules: entries, skippedCount, hasEvaluationState };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Determine if a rule file is project-local or global.
|
|
117
|
+
* Uses path.sep boundary check to avoid matching partial prefixes
|
|
118
|
+
* (e.g., /project/.opencode/rules-extra/ should not match).
|
|
119
|
+
*/
|
|
120
|
+
export function ruleSource(
|
|
121
|
+
filePath: string,
|
|
122
|
+
projectDir: string | null
|
|
123
|
+
): 'global' | 'project' {
|
|
124
|
+
if (!projectDir) return 'global';
|
|
125
|
+
const projectRulesPrefix =
|
|
126
|
+
path.join(projectDir, '.opencode', 'rules') + path.sep;
|
|
127
|
+
return filePath.startsWith(projectRulesPrefix) ? 'project' : 'global';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if metadata has any conditional fields set.
|
|
132
|
+
*/
|
|
133
|
+
export function hasConditions(meta: RuleMetadata | undefined): boolean {
|
|
134
|
+
if (!meta) return false;
|
|
135
|
+
return !!(
|
|
136
|
+
meta.globs ||
|
|
137
|
+
meta.keywords ||
|
|
138
|
+
meta.tools ||
|
|
139
|
+
meta.model ||
|
|
140
|
+
meta.agent ||
|
|
141
|
+
meta.command ||
|
|
142
|
+
meta.project ||
|
|
143
|
+
meta.branch ||
|
|
144
|
+
meta.os ||
|
|
145
|
+
meta.ci !== undefined
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a human-readable, comma-separated summary of active conditions.
|
|
151
|
+
* E.g., "globs: src/*.ts, keywords: auth, security"
|
|
152
|
+
*/
|
|
153
|
+
export function formatConditionSummary(meta: RuleMetadata): string {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
|
|
156
|
+
const arrayFields: Array<[keyof RuleMetadata, string]> = [
|
|
157
|
+
['globs', 'globs'],
|
|
158
|
+
['keywords', 'keywords'],
|
|
159
|
+
['tools', 'tools'],
|
|
160
|
+
['model', 'model'],
|
|
161
|
+
['agent', 'agent'],
|
|
162
|
+
['command', 'command'],
|
|
163
|
+
['project', 'project'],
|
|
164
|
+
['branch', 'branch'],
|
|
165
|
+
['os', 'os'],
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (const [field, label] of arrayFields) {
|
|
169
|
+
const value = meta[field];
|
|
170
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
171
|
+
parts.push(`${label}: ${(value as string[]).join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (meta.ci !== undefined) {
|
|
176
|
+
parts.push(`ci: ${String(meta.ci)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (meta.match) {
|
|
180
|
+
parts.push(`match: ${meta.match}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return parts.join(', ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Three-pass name disambiguation.
|
|
188
|
+
* Pass 1: Extract filename stem from each entry's path.
|
|
189
|
+
* Pass 2: For duplicate stems, prefix with parent directory.
|
|
190
|
+
* Pass 3: If still ambiguous (same parent or root-level), use full relative
|
|
191
|
+
* path (including extension) as the display name.
|
|
192
|
+
*
|
|
193
|
+
* Mutates entries[].name in place.
|
|
194
|
+
*/
|
|
195
|
+
export function disambiguateNames(entries: SidebarRuleEntry[]): void {
|
|
196
|
+
// Pass 1: assign stem names (filename without extension, using last dot)
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const basename = path.basename(entry.path);
|
|
199
|
+
const dotIndex = basename.lastIndexOf('.');
|
|
200
|
+
entry.name = dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Pass 2: detect and resolve collisions with parent directory prefix
|
|
204
|
+
const stemCounts = new Map<string, number>();
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
stemCounts.set(entry.name, (stemCounts.get(entry.name) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
if ((stemCounts.get(entry.name) ?? 0) <= 1) continue;
|
|
211
|
+
|
|
212
|
+
const dir = path.dirname(entry.path);
|
|
213
|
+
if (dir && dir !== '.') {
|
|
214
|
+
const parent = path.basename(dir);
|
|
215
|
+
entry.name = `${parent}/${entry.name}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Pass 3: if still ambiguous, use full relative path WITH extension
|
|
220
|
+
const nameCounts = new Map<string, number>();
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if ((nameCounts.get(entry.name) ?? 0) > 1) {
|
|
227
|
+
entry.name = entry.path;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/tui/index.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// tui/index.tsx
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
import type { TuiPlugin } from '@opencode-ai/plugin/tui';
|
|
4
|
+
import { SidebarContent } from './slots/sidebar-content';
|
|
5
|
+
|
|
6
|
+
const id = 'opencode-rules-md' as const;
|
|
7
|
+
|
|
8
|
+
const tui: TuiPlugin = async api => {
|
|
9
|
+
api.slots.register({
|
|
10
|
+
order: 350,
|
|
11
|
+
slots: {
|
|
12
|
+
sidebar_content: (ctx, props) => (
|
|
13
|
+
<SidebarContent
|
|
14
|
+
sessionId={props.session_id}
|
|
15
|
+
api={api}
|
|
16
|
+
theme={ctx.theme}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default { id, tui };
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// tui/slots/sidebar-content.tsx
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
import {
|
|
4
|
+
createSignal,
|
|
5
|
+
createEffect,
|
|
6
|
+
createMemo,
|
|
7
|
+
onCleanup,
|
|
8
|
+
Show,
|
|
9
|
+
For,
|
|
10
|
+
type JSX,
|
|
11
|
+
} from 'solid-js';
|
|
12
|
+
import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui';
|
|
13
|
+
import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules';
|
|
14
|
+
|
|
15
|
+
interface SidebarContentProps {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
api: TuiPluginApi;
|
|
18
|
+
theme: TuiTheme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ThemeColor = string | import('@opentui/core').RGBA;
|
|
22
|
+
|
|
23
|
+
interface ThemeColors {
|
|
24
|
+
text: ThemeColor;
|
|
25
|
+
textMuted: ThemeColor;
|
|
26
|
+
success: ThemeColor;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RuleSectionProps {
|
|
31
|
+
title: string;
|
|
32
|
+
rules: SidebarRuleEntry[];
|
|
33
|
+
theme: ThemeColors;
|
|
34
|
+
open: boolean;
|
|
35
|
+
onToggle: () => void;
|
|
36
|
+
expandedIndex: number | null;
|
|
37
|
+
globalOffset: number;
|
|
38
|
+
onExpandToggle: (globalIndex: number) => void;
|
|
39
|
+
hasEvaluationState: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function RuleSection(props: RuleSectionProps): JSX.Element {
|
|
43
|
+
const activeCount = createMemo(
|
|
44
|
+
() => props.rules.filter(r => r.isActive === true).length
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const headerCount = createMemo(() => {
|
|
48
|
+
if (props.hasEvaluationState) {
|
|
49
|
+
return `(${activeCount()}/${props.rules.length})`;
|
|
50
|
+
}
|
|
51
|
+
return `(${props.rules.length})`;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const bulletColor = (rule: SidebarRuleEntry): ThemeColor => {
|
|
55
|
+
return rule.isActive === true ? props.theme.success : props.theme.textMuted;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Show when={props.rules.length > 0}>
|
|
60
|
+
<box>
|
|
61
|
+
<box flexDirection="row" gap={1} onMouseDown={() => props.onToggle()}>
|
|
62
|
+
<text fg={props.theme.text}>{props.open ? '▼' : '▶'}</text>
|
|
63
|
+
<text fg={props.theme.text}>
|
|
64
|
+
{props.title}
|
|
65
|
+
<Show when={!props.open}>
|
|
66
|
+
<span style={{ fg: props.theme.textMuted }}>
|
|
67
|
+
{' '}
|
|
68
|
+
{headerCount()}
|
|
69
|
+
</span>
|
|
70
|
+
</Show>
|
|
71
|
+
</text>
|
|
72
|
+
</box>
|
|
73
|
+
<Show when={props.open}>
|
|
74
|
+
<For each={props.rules}>
|
|
75
|
+
{(rule, localIndex) => {
|
|
76
|
+
const globalIndex = () => props.globalOffset + localIndex();
|
|
77
|
+
return (
|
|
78
|
+
<box
|
|
79
|
+
flexDirection="column"
|
|
80
|
+
onMouseDown={() => props.onExpandToggle(globalIndex())}
|
|
81
|
+
>
|
|
82
|
+
<box flexDirection="row" gap={1}>
|
|
83
|
+
<text fg={bulletColor(rule)}>•</text>
|
|
84
|
+
<text fg={props.theme.text}>{rule.name}</text>
|
|
85
|
+
</box>
|
|
86
|
+
<Show when={props.expandedIndex === globalIndex()}>
|
|
87
|
+
<box flexDirection="column" paddingLeft={4}>
|
|
88
|
+
<text fg={props.theme.textMuted}>{rule.path}</text>
|
|
89
|
+
<Show when={(rule.metadata.globs?.length ?? 0) > 0}>
|
|
90
|
+
<text fg={props.theme.textMuted}>
|
|
91
|
+
Globs: {rule.metadata.globs!.join(', ')}
|
|
92
|
+
</text>
|
|
93
|
+
</Show>
|
|
94
|
+
<Show when={(rule.metadata.keywords?.length ?? 0) > 0}>
|
|
95
|
+
<text fg={props.theme.textMuted}>
|
|
96
|
+
Keywords: {rule.metadata.keywords!.join(', ')}
|
|
97
|
+
</text>
|
|
98
|
+
</Show>
|
|
99
|
+
<Show when={(rule.metadata.tools?.length ?? 0) > 0}>
|
|
100
|
+
<text fg={props.theme.textMuted}>
|
|
101
|
+
Tools: {rule.metadata.tools!.join(', ')}
|
|
102
|
+
</text>
|
|
103
|
+
</Show>
|
|
104
|
+
<Show when={(rule.metadata.model?.length ?? 0) > 0}>
|
|
105
|
+
<text fg={props.theme.textMuted}>
|
|
106
|
+
Model: {rule.metadata.model!.join(', ')}
|
|
107
|
+
</text>
|
|
108
|
+
</Show>
|
|
109
|
+
<Show when={(rule.metadata.agent?.length ?? 0) > 0}>
|
|
110
|
+
<text fg={props.theme.textMuted}>
|
|
111
|
+
Agent: {rule.metadata.agent!.join(', ')}
|
|
112
|
+
</text>
|
|
113
|
+
</Show>
|
|
114
|
+
<Show when={(rule.metadata.command?.length ?? 0) > 0}>
|
|
115
|
+
<text fg={props.theme.textMuted}>
|
|
116
|
+
Command: {rule.metadata.command!.join(', ')}
|
|
117
|
+
</text>
|
|
118
|
+
</Show>
|
|
119
|
+
<Show when={(rule.metadata.project?.length ?? 0) > 0}>
|
|
120
|
+
<text fg={props.theme.textMuted}>
|
|
121
|
+
Project: {rule.metadata.project!.join(', ')}
|
|
122
|
+
</text>
|
|
123
|
+
</Show>
|
|
124
|
+
<Show when={(rule.metadata.branch?.length ?? 0) > 0}>
|
|
125
|
+
<text fg={props.theme.textMuted}>
|
|
126
|
+
Branch: {rule.metadata.branch!.join(', ')}
|
|
127
|
+
</text>
|
|
128
|
+
</Show>
|
|
129
|
+
<Show when={(rule.metadata.os?.length ?? 0) > 0}>
|
|
130
|
+
<text fg={props.theme.textMuted}>
|
|
131
|
+
OS: {rule.metadata.os!.join(', ')}
|
|
132
|
+
</text>
|
|
133
|
+
</Show>
|
|
134
|
+
<Show when={rule.metadata.ci !== undefined}>
|
|
135
|
+
<text fg={props.theme.textMuted}>
|
|
136
|
+
CI: {String(rule.metadata.ci)}
|
|
137
|
+
</text>
|
|
138
|
+
</Show>
|
|
139
|
+
<Show when={rule.metadata.match}>
|
|
140
|
+
<text fg={props.theme.textMuted}>
|
|
141
|
+
Match: {rule.metadata.match}
|
|
142
|
+
</text>
|
|
143
|
+
</Show>
|
|
144
|
+
</box>
|
|
145
|
+
</Show>
|
|
146
|
+
</box>
|
|
147
|
+
);
|
|
148
|
+
}}
|
|
149
|
+
</For>
|
|
150
|
+
</Show>
|
|
151
|
+
</box>
|
|
152
|
+
</Show>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function SidebarContent(props: SidebarContentProps): JSX.Element {
|
|
157
|
+
const [rules, setRules] = createSignal<SidebarRuleEntry[]>([]);
|
|
158
|
+
const [status, setStatus] = createSignal<'loading' | 'loaded' | 'error'>(
|
|
159
|
+
'loading'
|
|
160
|
+
);
|
|
161
|
+
const [skippedCount, setSkippedCount] = createSignal(0);
|
|
162
|
+
const [hasEvaluationState, setHasEvaluationState] = createSignal(false);
|
|
163
|
+
const [expandedIndex, setExpandedIndex] = createSignal<number | null>(null);
|
|
164
|
+
const [lastDir, setLastDir] = createSignal<string | null | undefined>(
|
|
165
|
+
undefined
|
|
166
|
+
);
|
|
167
|
+
const [lastSessionId, setLastSessionId] = createSignal<string | undefined>(
|
|
168
|
+
undefined
|
|
169
|
+
);
|
|
170
|
+
const [projectOpen, setProjectOpen] = createSignal(false);
|
|
171
|
+
const [globalOpen, setGlobalOpen] = createSignal(false);
|
|
172
|
+
const [refreshCounter, setRefreshCounter] = createSignal(0);
|
|
173
|
+
|
|
174
|
+
const theme = (): ThemeColors => props.theme.current as ThemeColors;
|
|
175
|
+
|
|
176
|
+
const resolveProjectDir = (): string | null => {
|
|
177
|
+
return props.api.state.path.directory ?? null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Monotonic counter to detect stale async results
|
|
181
|
+
let requestId = 0;
|
|
182
|
+
// Debounce timer for event-driven refresh
|
|
183
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
184
|
+
|
|
185
|
+
// Initial load: triggered by session/directory change, resets all UI state
|
|
186
|
+
const loadRulesInitial = async (): Promise<void> => {
|
|
187
|
+
const thisRequest = ++requestId;
|
|
188
|
+
const dir = resolveProjectDir();
|
|
189
|
+
const sessionId = props.sessionId;
|
|
190
|
+
|
|
191
|
+
setLastDir(dir);
|
|
192
|
+
setLastSessionId(sessionId);
|
|
193
|
+
setStatus('loading');
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const result = await loadSidebarRules(dir, sessionId);
|
|
197
|
+
// Discard if a newer request started
|
|
198
|
+
if (requestId !== thisRequest) return;
|
|
199
|
+
setRules(result.rules);
|
|
200
|
+
setSkippedCount(result.skippedCount);
|
|
201
|
+
setHasEvaluationState(result.hasEvaluationState);
|
|
202
|
+
setStatus('loaded');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Discard if a newer request started
|
|
205
|
+
if (requestId !== thisRequest) return;
|
|
206
|
+
console.error('[opencode-rules-md] Failed to load rules:', err);
|
|
207
|
+
setStatus('error');
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Refresh load: triggered by events, only updates rule data (no UI state reset)
|
|
212
|
+
const loadRulesRefresh = async (): Promise<void> => {
|
|
213
|
+
// Skip refresh if initial load is still in flight
|
|
214
|
+
if (status() === 'loading') return;
|
|
215
|
+
|
|
216
|
+
const thisRequest = ++requestId;
|
|
217
|
+
const dir = resolveProjectDir();
|
|
218
|
+
const sessionId = props.sessionId;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const result = await loadSidebarRules(dir, sessionId);
|
|
222
|
+
// Discard if a newer request started
|
|
223
|
+
if (requestId !== thisRequest) return;
|
|
224
|
+
setRules(result.rules);
|
|
225
|
+
setSkippedCount(result.skippedCount);
|
|
226
|
+
setHasEvaluationState(result.hasEvaluationState);
|
|
227
|
+
setStatus('loaded');
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// Discard if a newer request started
|
|
230
|
+
if (requestId !== thisRequest) return;
|
|
231
|
+
console.error('[opencode-rules-md] Failed to refresh rules:', err);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Effect 1: Initial load on session/directory change
|
|
236
|
+
createEffect(() => {
|
|
237
|
+
const currentSessionId = props.sessionId;
|
|
238
|
+
const currentDir = resolveProjectDir();
|
|
239
|
+
|
|
240
|
+
// Check if session or directory changed
|
|
241
|
+
if (currentSessionId !== lastSessionId() || currentDir !== lastDir()) {
|
|
242
|
+
// Clear pending debounce from previous session
|
|
243
|
+
if (debounceTimer !== null) {
|
|
244
|
+
clearTimeout(debounceTimer);
|
|
245
|
+
debounceTimer = null;
|
|
246
|
+
}
|
|
247
|
+
// Reset UI state on session/directory change
|
|
248
|
+
setExpandedIndex(null);
|
|
249
|
+
setProjectOpen(false);
|
|
250
|
+
setGlobalOpen(false);
|
|
251
|
+
void loadRulesInitial();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Effect 2: Refresh on event-driven updates (refreshCounter changes)
|
|
256
|
+
createEffect(() => {
|
|
257
|
+
const counter = refreshCounter();
|
|
258
|
+
if (counter > 0) {
|
|
259
|
+
void loadRulesRefresh();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Subscribe to OpenCode events with debounce
|
|
264
|
+
const triggerRefresh = (event: {
|
|
265
|
+
type: string;
|
|
266
|
+
properties: Record<string, unknown>;
|
|
267
|
+
}): void => {
|
|
268
|
+
// Filter events to current sessionId before debouncing
|
|
269
|
+
// OpenCode SDK events nest sessionID inside properties: { type, properties: { sessionID, ... } }
|
|
270
|
+
const eventSessionID = event.properties.sessionID;
|
|
271
|
+
if (
|
|
272
|
+
typeof eventSessionID === 'string' &&
|
|
273
|
+
eventSessionID !== props.sessionId
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (debounceTimer !== null) {
|
|
279
|
+
clearTimeout(debounceTimer);
|
|
280
|
+
}
|
|
281
|
+
debounceTimer = setTimeout(() => {
|
|
282
|
+
debounceTimer = null;
|
|
283
|
+
setRefreshCounter(c => c + 1);
|
|
284
|
+
}, 150);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const unsubMessageUpdated = props.api.event.on(
|
|
288
|
+
'message.updated',
|
|
289
|
+
triggerRefresh
|
|
290
|
+
);
|
|
291
|
+
const unsubSessionStatus = props.api.event.on(
|
|
292
|
+
'session.status',
|
|
293
|
+
triggerRefresh
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
onCleanup(() => {
|
|
297
|
+
if (debounceTimer !== null) {
|
|
298
|
+
clearTimeout(debounceTimer);
|
|
299
|
+
}
|
|
300
|
+
unsubMessageUpdated();
|
|
301
|
+
unsubSessionStatus();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const toggleExpand = (index: number): void => {
|
|
305
|
+
setExpandedIndex(prev => (prev === index ? null : index));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const projectRules = createMemo(() =>
|
|
309
|
+
rules().filter(r => r.source === 'project')
|
|
310
|
+
);
|
|
311
|
+
const globalRules = createMemo(() =>
|
|
312
|
+
rules().filter(r => r.source === 'global')
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<box>
|
|
317
|
+
<text fg={theme().text}>
|
|
318
|
+
<b>Rules</b>
|
|
319
|
+
</text>
|
|
320
|
+
|
|
321
|
+
<Show when={status() === 'loading'}>
|
|
322
|
+
<text fg={theme().textMuted}>Loading...</text>
|
|
323
|
+
</Show>
|
|
324
|
+
<Show when={status() === 'error'}>
|
|
325
|
+
<text fg={theme().textMuted}>Failed to load rules</text>
|
|
326
|
+
</Show>
|
|
327
|
+
|
|
328
|
+
<Show when={status() === 'loaded'}>
|
|
329
|
+
<Show
|
|
330
|
+
when={rules().length > 0}
|
|
331
|
+
fallback={<text fg={theme().textMuted}>No rules found</text>}
|
|
332
|
+
>
|
|
333
|
+
<RuleSection
|
|
334
|
+
title="Project"
|
|
335
|
+
rules={projectRules()}
|
|
336
|
+
theme={theme()}
|
|
337
|
+
open={projectOpen()}
|
|
338
|
+
onToggle={() => setProjectOpen(x => !x)}
|
|
339
|
+
expandedIndex={expandedIndex()}
|
|
340
|
+
globalOffset={0}
|
|
341
|
+
onExpandToggle={toggleExpand}
|
|
342
|
+
hasEvaluationState={hasEvaluationState()}
|
|
343
|
+
/>
|
|
344
|
+
<RuleSection
|
|
345
|
+
title="Global"
|
|
346
|
+
rules={globalRules()}
|
|
347
|
+
theme={theme()}
|
|
348
|
+
open={globalOpen()}
|
|
349
|
+
onToggle={() => setGlobalOpen(x => !x)}
|
|
350
|
+
expandedIndex={expandedIndex()}
|
|
351
|
+
globalOffset={projectRules().length}
|
|
352
|
+
onExpandToggle={toggleExpand}
|
|
353
|
+
hasEvaluationState={hasEvaluationState()}
|
|
354
|
+
/>
|
|
355
|
+
</Show>
|
|
356
|
+
<Show when={skippedCount() > 0}>
|
|
357
|
+
<text fg={theme().textMuted}>
|
|
358
|
+
{skippedCount()} rules skipped (unreadable)
|
|
359
|
+
</text>
|
|
360
|
+
</Show>
|
|
361
|
+
</Show>
|
|
362
|
+
</box>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tui/types/opencode-plugin-tui.d.ts
|
|
2
|
+
//
|
|
3
|
+
// Vendored type declarations for @opencode-ai/plugin/tui.
|
|
4
|
+
// Allows tsc to compile TUI code without requiring the optional
|
|
5
|
+
// peer dependency to be installed at compile time.
|
|
6
|
+
//
|
|
7
|
+
// Source: @opencode-ai/plugin v1.3.7 (packages/plugin/src/tui.ts)
|
|
8
|
+
// If bumping @opencode-ai/plugin, re-verify these types match.
|
|
9
|
+
|
|
10
|
+
declare module '@opencode-ai/plugin/tui' {
|
|
11
|
+
export interface TuiTheme {
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TuiSlotMap {
|
|
16
|
+
sidebar_content: { session_id: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TuiSlotContext {
|
|
20
|
+
theme: TuiTheme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SlotRenderer<K extends keyof TuiSlotMap> = (
|
|
24
|
+
ctx: Readonly<TuiSlotContext>,
|
|
25
|
+
props: TuiSlotMap[K]
|
|
26
|
+
) => JSX.Element;
|
|
27
|
+
|
|
28
|
+
export interface TuiSlotPlugin {
|
|
29
|
+
id?: never;
|
|
30
|
+
order?: number;
|
|
31
|
+
setup?: (ctx: Readonly<TuiSlotContext>, renderer: unknown) => void;
|
|
32
|
+
dispose?: () => void;
|
|
33
|
+
slots: {
|
|
34
|
+
[K in keyof TuiSlotMap]?: SlotRenderer<K>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TuiSlots {
|
|
39
|
+
register: (plugin: TuiSlotPlugin) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TuiWorkspace {
|
|
43
|
+
current: () => string | undefined;
|
|
44
|
+
set: (id?: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TuiState {
|
|
48
|
+
readonly path: {
|
|
49
|
+
state: string;
|
|
50
|
+
config: string;
|
|
51
|
+
worktree: string;
|
|
52
|
+
directory: string;
|
|
53
|
+
};
|
|
54
|
+
workspace: {
|
|
55
|
+
get: (id: string) => { directory: string | null } | undefined;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TuiEventBus {
|
|
60
|
+
on: (
|
|
61
|
+
type: string,
|
|
62
|
+
handler: (event: {
|
|
63
|
+
type: string;
|
|
64
|
+
properties: Record<string, unknown>;
|
|
65
|
+
}) => void
|
|
66
|
+
) => () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TuiPluginApi {
|
|
70
|
+
slots: TuiSlots;
|
|
71
|
+
workspace: TuiWorkspace;
|
|
72
|
+
state: TuiState;
|
|
73
|
+
kv: unknown;
|
|
74
|
+
event: TuiEventBus;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TuiPlugin = (api: TuiPluginApi) => Promise<void> | void;
|
|
78
|
+
}
|