@albinocrabs/o-switcher 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.tsx ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * O-Switcher TUI plugin for OpenCode.
3
+ *
4
+ * Provides:
5
+ * - sidebar_footer: compact one-line status (active profile, health %, target count)
6
+ * - /o-switcher-status route: full dashboard with all targets and their states
7
+ * - /switcher slash command: navigates to the dashboard
8
+ *
9
+ * State is read from a file bridge written by the server plugin (state-bridge.ts).
10
+ * TUI plugins are loaded by Bun with babel-preset-solid transform — this file
11
+ * ships as source .tsx and is compiled at load time.
12
+ *
13
+ * NOTE: This file is excluded from tsconfig typecheck and tsup bundling.
14
+ * It runs in Bun's Solid.js context, not Node.js.
15
+ */
16
+
17
+ import { createSignal, onCleanup } from 'solid-js';
18
+ import type {
19
+ TuiPluginModule,
20
+ TuiPluginApi,
21
+ TuiSlotContext,
22
+ } from '@opencode-ai/plugin/tui';
23
+ import { readTuiState } from './state-bridge.js';
24
+ import type { TuiStateFile, TuiTargetSummary } from './state-bridge.js';
25
+
26
+ // ── Constants ─────────────────────────────────────────────────────
27
+
28
+ const POLL_INTERVAL_MS = 2000;
29
+
30
+ // ── Helpers ───────────────────────────────────────────────────────
31
+
32
+ /** Format health score as percentage string. */
33
+ const healthPct = (score: number): string => `${Math.round(score * 100)}%`;
34
+
35
+ /** State icon by target state. */
36
+ const stateIcon = (state: TuiTargetSummary['state']): string => {
37
+ const icons: Record<TuiTargetSummary['state'], string> = {
38
+ Active: '●',
39
+ CoolingDown: '◐',
40
+ ReauthRequired: '⚠',
41
+ PolicyBlocked: '✕',
42
+ CircuitOpen: '○',
43
+ CircuitHalfOpen: '◑',
44
+ Draining: '▽',
45
+ Disabled: '—',
46
+ };
47
+ return icons[state];
48
+ };
49
+
50
+ /** Display name: prefer profile, fall back to target_id. */
51
+ const displayName = (t: TuiTargetSummary): string =>
52
+ t.profile ?? t.target_id;
53
+
54
+ // ── State polling hook ────────────────────────────────────────────
55
+
56
+ const useTuiState = () => {
57
+ const [state, setState] = createSignal<TuiStateFile | undefined>();
58
+
59
+ const timer = setInterval(async () => {
60
+ const s = await readTuiState();
61
+ setState(s);
62
+ }, POLL_INTERVAL_MS);
63
+
64
+ // Immediate first read
65
+ readTuiState().then((s) => setState(s));
66
+
67
+ onCleanup(() => clearInterval(timer));
68
+
69
+ return state;
70
+ };
71
+
72
+ // ── Components ────────────────────────────────────────────────────
73
+
74
+ /** Sidebar footer: compact one-line status indicator. */
75
+ const SidebarFooter = () => {
76
+ const state = useTuiState();
77
+
78
+ const label = (): string => {
79
+ const s = state();
80
+ if (!s || s.targets.length === 0) return '◌ o-switcher: no targets';
81
+
82
+ const activeTargets = s.targets.filter(
83
+ (t) => t.enabled && t.state === 'Active',
84
+ );
85
+ const totalEnabled = s.targets.filter((t) => t.enabled).length;
86
+
87
+ // Show the current active target name + health
88
+ const current = s.active_target_id
89
+ ? s.targets.find((t) => t.target_id === s.active_target_id)
90
+ : activeTargets[0];
91
+
92
+ if (!current) {
93
+ return `○ o-switcher: 0/${totalEnabled} active`;
94
+ }
95
+
96
+ const name = displayName(current);
97
+ const health = healthPct(current.health_score);
98
+ return `◉ ${name} (${health}) | ${activeTargets.length}/${totalEnabled} active`;
99
+ };
100
+
101
+ return <text truncate={true}>{label()}</text>;
102
+ };
103
+
104
+ /** Full status dashboard route. */
105
+ const StatusDashboard = () => {
106
+ const state = useTuiState();
107
+
108
+ const content = (): string => {
109
+ const s = state();
110
+ if (!s) return ' Loading O-Switcher state...';
111
+ if (s.targets.length === 0) return ' No targets configured.';
112
+
113
+ const lines: string[] = [
114
+ ' O-Switcher Status',
115
+ ' ' + '─'.repeat(50),
116
+ '',
117
+ ];
118
+
119
+ // Header row
120
+ lines.push(
121
+ ' ' +
122
+ 'Target'.padEnd(24) +
123
+ 'State'.padEnd(14) +
124
+ 'Health'.padEnd(10) +
125
+ 'Latency',
126
+ );
127
+ lines.push(' ' + '─'.repeat(50));
128
+
129
+ for (const t of s.targets) {
130
+ const icon = stateIcon(t.state);
131
+ const name = displayName(t).slice(0, 22);
132
+ const stateStr = `${icon} ${t.state}`;
133
+ const health = healthPct(t.health_score);
134
+ const latency = t.latency_ema_ms > 0 ? `${Math.round(t.latency_ema_ms)}ms` : '—';
135
+ const activeMarker = t.target_id === s.active_target_id ? ' ◀' : '';
136
+ const disabledMark = t.enabled ? '' : ' (off)';
137
+
138
+ lines.push(
139
+ ' ' +
140
+ (name + disabledMark).padEnd(24) +
141
+ stateStr.padEnd(14) +
142
+ health.padEnd(10) +
143
+ latency +
144
+ activeMarker,
145
+ );
146
+ }
147
+
148
+ // Summary
149
+ const activeCount = s.targets.filter(
150
+ (t) => t.enabled && t.state === 'Active',
151
+ ).length;
152
+ const totalEnabled = s.targets.filter((t) => t.enabled).length;
153
+ const avgHealth =
154
+ totalEnabled > 0
155
+ ? s.targets
156
+ .filter((t) => t.enabled)
157
+ .reduce((sum, t) => sum + t.health_score, 0) / totalEnabled
158
+ : 0;
159
+
160
+ lines.push('');
161
+ lines.push(' ' + '─'.repeat(50));
162
+ lines.push(
163
+ ` ${activeCount}/${totalEnabled} targets active | avg health: ${healthPct(avgHealth)}`,
164
+ );
165
+
166
+ const age = Date.now() - s.updated_at;
167
+ const ageSec = Math.round(age / 1000);
168
+ lines.push(` Updated ${ageSec}s ago`);
169
+
170
+ return lines.join('\n');
171
+ };
172
+
173
+ return <text wrapMode="none">{content()}</text>;
174
+ };
175
+
176
+ // ── Plugin entry point ────────────────────────────────────────────
177
+
178
+ const tui = async (api: TuiPluginApi) => {
179
+ // Register sidebar footer slot
180
+ api.slots.register({
181
+ slots: {
182
+ sidebar_footer: (_ctx: TuiSlotContext, _props: { session_id: string }) => (
183
+ <SidebarFooter />
184
+ ),
185
+ },
186
+ });
187
+
188
+ // Register status dashboard route
189
+ api.route.register([
190
+ {
191
+ name: 'o-switcher-status',
192
+ render: () => <StatusDashboard />,
193
+ },
194
+ ]);
195
+
196
+ // Register /switcher slash command
197
+ api.command.register(() => [
198
+ {
199
+ title: 'O-Switcher Status',
200
+ value: 'o-switcher-status',
201
+ description: 'Show target health and routing status',
202
+ slash: {
203
+ name: 'switcher',
204
+ aliases: ['osw'],
205
+ },
206
+ onSelect: () => {
207
+ api.route.navigate('o-switcher-status');
208
+ },
209
+ },
210
+ ]);
211
+ };
212
+
213
+ const tuiModule: TuiPluginModule = { tui };
214
+ export default tuiModule;
package/CONTRIBUTING.md DELETED
@@ -1,72 +0,0 @@
1
- # Contributing to O-Switcher
2
-
3
- Thank you for your interest in contributing!
4
-
5
- ## Getting Started
6
-
7
- ```bash
8
- git clone https://github.com/<owner>/o-switcher.git
9
- cd o-switcher
10
- npm install
11
- npm test # 351 tests, all must pass
12
- npm run typecheck # zero TypeScript errors
13
- ```
14
-
15
- ## Development Workflow
16
-
17
- 1. **Fork** the repository
18
- 2. **Create a branch** from `main`: `git checkout -b feat/my-feature`
19
- 3. **Write tests first** (TDD) — tests live next to source files (`*.test.ts`)
20
- 4. **Implement** — make the tests pass
21
- 5. **Verify** — `npm test && npm run typecheck`
22
- 6. **Commit** — use [conventional commits](https://www.conventionalcommits.org/):
23
- `feat(routing):`, `fix(config):`, `test(execution):`, `docs:`
24
- 7. **Open a PR** against `main`
25
-
26
- ## Project Structure
27
-
28
- ```
29
- src/
30
- ├── config/ Config loading, Zod schema validation
31
- ├── registry/ Target registry, health scoring, mode detection
32
- ├── errors/ Error taxonomy (10 classes), dual-mode classifier
33
- ├── retry/ Bounded retry with backoff, jitter, Retry-After
34
- ├── routing/ Policy engine, circuit breaker, admission, failover
35
- ├── execution/ Mode adapters, stream stitcher, audit collector
36
- ├── operator/ Operator commands, config reload, plugin tools
37
- ├── integration/ End-to-end integration tests
38
- ├── audit/ Pino-based structured audit logger
39
- ├── mode/ Deployment mode types and capabilities
40
- └── spike/ Proof-of-concept explorations
41
- ```
42
-
43
- ## Code Conventions
44
-
45
- - **TypeScript strict mode** — `noUncheckedIndexedAccess`, `verbatimModuleSyntax`
46
- - **ESM** — `"type": "module"` in package.json
47
- - **Factory functions** — `createXxx()` returning interfaces (not classes)
48
- - **Pure functions** — routing/scoring logic has no I/O
49
- - **No `let`** — use `const` only, restructure if needed
50
- - **No `!!`** — use `Boolean()` for explicit coercion
51
- - **Vitest** for testing — co-located `*.test.ts` files
52
-
53
- ## Testing
54
-
55
- ```bash
56
- npm test # all tests
57
- npx vitest run src/routing/ # specific module
58
- npx vitest run --watch # watch mode
59
- ```
60
-
61
- ## Architecture
62
-
63
- See [README.md](README.md) for the architecture diagram and module overview.
64
-
65
- ## Reporting Issues
66
-
67
- - Use GitHub Issues
68
- - Include: steps to reproduce, expected vs actual behavior, Node.js version
69
-
70
- ## License
71
-
72
- By contributing, you agree that your contributions will be licensed under the MIT License.