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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +650 -0
  3. package/dist/cli.mjs +374 -0
  4. package/dist/src/active-rules-state.d.ts +12 -0
  5. package/dist/src/active-rules-state.d.ts.map +1 -0
  6. package/dist/src/active-rules-state.js +118 -0
  7. package/dist/src/active-rules-state.js.map +1 -0
  8. package/dist/src/cli/config.d.ts +62 -0
  9. package/dist/src/cli/config.d.ts.map +1 -0
  10. package/dist/src/cli/config.js +246 -0
  11. package/dist/src/cli/config.js.map +1 -0
  12. package/dist/src/cli/install.d.ts +21 -0
  13. package/dist/src/cli/install.d.ts.map +1 -0
  14. package/dist/src/cli/install.js +63 -0
  15. package/dist/src/cli/install.js.map +1 -0
  16. package/dist/src/cli/main.d.ts +19 -0
  17. package/dist/src/cli/main.d.ts.map +1 -0
  18. package/dist/src/cli/main.js +135 -0
  19. package/dist/src/cli/main.js.map +1 -0
  20. package/dist/src/cli/real-fs.d.ts +26 -0
  21. package/dist/src/cli/real-fs.d.ts.map +1 -0
  22. package/dist/src/cli/real-fs.js +42 -0
  23. package/dist/src/cli/real-fs.js.map +1 -0
  24. package/dist/src/cli/status.d.ts +19 -0
  25. package/dist/src/cli/status.d.ts.map +1 -0
  26. package/dist/src/cli/status.js +58 -0
  27. package/dist/src/cli/status.js.map +1 -0
  28. package/dist/src/debug.d.ts +3 -0
  29. package/dist/src/debug.d.ts.map +1 -0
  30. package/dist/src/debug.js +8 -0
  31. package/dist/src/debug.js.map +1 -0
  32. package/dist/src/git-branch.d.ts +2 -0
  33. package/dist/src/git-branch.d.ts.map +1 -0
  34. package/dist/src/git-branch.js +30 -0
  35. package/dist/src/git-branch.js.map +1 -0
  36. package/dist/src/index.d.ts +26 -0
  37. package/dist/src/index.d.ts.map +1 -0
  38. package/dist/src/index.js +55 -0
  39. package/dist/src/index.js.map +1 -0
  40. package/dist/src/mcp-tools.d.ts +8 -0
  41. package/dist/src/mcp-tools.d.ts.map +1 -0
  42. package/dist/src/mcp-tools.js +18 -0
  43. package/dist/src/mcp-tools.js.map +1 -0
  44. package/dist/src/message-context.d.ts +55 -0
  45. package/dist/src/message-context.d.ts.map +1 -0
  46. package/dist/src/message-context.js +110 -0
  47. package/dist/src/message-context.js.map +1 -0
  48. package/dist/src/message-paths.d.ts +34 -0
  49. package/dist/src/message-paths.d.ts.map +1 -0
  50. package/dist/src/message-paths.js +112 -0
  51. package/dist/src/message-paths.js.map +1 -0
  52. package/dist/src/project-fingerprint.d.ts +2 -0
  53. package/dist/src/project-fingerprint.d.ts.map +1 -0
  54. package/dist/src/project-fingerprint.js +61 -0
  55. package/dist/src/project-fingerprint.js.map +1 -0
  56. package/dist/src/rule-discovery.d.ts +49 -0
  57. package/dist/src/rule-discovery.d.ts.map +1 -0
  58. package/dist/src/rule-discovery.js +143 -0
  59. package/dist/src/rule-discovery.js.map +1 -0
  60. package/dist/src/rule-filter.d.ts +61 -0
  61. package/dist/src/rule-filter.d.ts.map +1 -0
  62. package/dist/src/rule-filter.js +180 -0
  63. package/dist/src/rule-filter.js.map +1 -0
  64. package/dist/src/rule-metadata.d.ts +29 -0
  65. package/dist/src/rule-metadata.d.ts.map +1 -0
  66. package/dist/src/rule-metadata.js +100 -0
  67. package/dist/src/rule-metadata.js.map +1 -0
  68. package/dist/src/runtime-chat.d.ts +34 -0
  69. package/dist/src/runtime-chat.d.ts.map +1 -0
  70. package/dist/src/runtime-chat.js +39 -0
  71. package/dist/src/runtime-chat.js.map +1 -0
  72. package/dist/src/runtime-context.d.ts +25 -0
  73. package/dist/src/runtime-context.d.ts.map +1 -0
  74. package/dist/src/runtime-context.js +109 -0
  75. package/dist/src/runtime-context.js.map +1 -0
  76. package/dist/src/runtime.d.ts +31 -0
  77. package/dist/src/runtime.d.ts.map +1 -0
  78. package/dist/src/runtime.js +238 -0
  79. package/dist/src/runtime.js.map +1 -0
  80. package/dist/src/session-store.d.ts +34 -0
  81. package/dist/src/session-store.d.ts.map +1 -0
  82. package/dist/src/session-store.js +90 -0
  83. package/dist/src/session-store.js.map +1 -0
  84. package/dist/src/utils.d.ts +15 -0
  85. package/dist/src/utils.d.ts.map +1 -0
  86. package/dist/src/utils.js +19 -0
  87. package/dist/src/utils.js.map +1 -0
  88. package/dist/tui/data/rules.d.ts +63 -0
  89. package/dist/tui/data/rules.d.ts.map +1 -0
  90. package/dist/tui/data/rules.js +173 -0
  91. package/dist/tui/data/rules.js.map +1 -0
  92. package/dist/tui/index.d.ts +8 -0
  93. package/dist/tui/index.d.ts.map +1 -0
  94. package/dist/tui/index.js +13 -0
  95. package/dist/tui/index.js.map +1 -0
  96. package/dist/tui/slots/sidebar-content.d.ts +11 -0
  97. package/dist/tui/slots/sidebar-content.d.ts.map +1 -0
  98. package/dist/tui/slots/sidebar-content.js +150 -0
  99. package/dist/tui/slots/sidebar-content.js.map +1 -0
  100. package/package.json +101 -0
  101. package/src/active-rules-state.ts +166 -0
  102. package/src/cli/config.ts +312 -0
  103. package/src/cli/install.ts +94 -0
  104. package/src/cli/main.ts +167 -0
  105. package/src/cli/real-fs.ts +65 -0
  106. package/src/cli/status.ts +80 -0
  107. package/src/debug.ts +9 -0
  108. package/src/git-branch.ts +37 -0
  109. package/src/index.ts +68 -0
  110. package/src/mcp-tools.ts +24 -0
  111. package/src/message-context.ts +139 -0
  112. package/src/message-paths.ts +153 -0
  113. package/src/project-fingerprint.ts +72 -0
  114. package/src/rule-discovery.ts +205 -0
  115. package/src/rule-filter.ts +269 -0
  116. package/src/rule-metadata.ts +161 -0
  117. package/src/runtime-chat.ts +68 -0
  118. package/src/runtime-context.ts +136 -0
  119. package/src/runtime.ts +369 -0
  120. package/src/session-store.ts +129 -0
  121. package/src/utils.ts +36 -0
  122. package/tui/.atl/.skill-registry.cache.json +3 -0
  123. package/tui/.atl/skill-registry.md +57 -0
  124. package/tui/data/rules.ts +230 -0
  125. package/tui/index.tsx +23 -0
  126. package/tui/slots/sidebar-content.tsx +364 -0
  127. 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
+ }