@albinocrabs/o-switcher 0.1.1 → 0.3.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/CHANGELOG.md +18 -0
- package/dist/{chunk-VABBGKSR.cjs → chunk-H72U2MNG.cjs} +192 -16
- package/dist/{chunk-IKNWSNAS.js → chunk-XXH633FY.js} +190 -17
- package/dist/index.cjs +88 -145
- package/dist/index.d.cts +43 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +9 -77
- package/dist/plugin.cjs +118 -17
- package/dist/plugin.d.cts +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +103 -2
- package/package.json +11 -5
- package/src/registry/types.ts +65 -0
- package/src/state-bridge.ts +119 -0
- package/src/tui.tsx +218 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State bridge between server plugin and TUI plugin.
|
|
3
|
+
*
|
|
4
|
+
* Server and TUI plugins run in separate contexts (Node.js vs Bun/Solid).
|
|
5
|
+
* This module provides a file-based state bridge:
|
|
6
|
+
* - Server writes a JSON snapshot after each meaningful state change
|
|
7
|
+
* - TUI polls the file to render current state
|
|
8
|
+
*
|
|
9
|
+
* Writes are atomic (tmp → rename) and debounced to avoid thrashing.
|
|
10
|
+
* Reads tolerate missing/corrupt files gracefully.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import type { TargetState } from './registry/types.js';
|
|
17
|
+
|
|
18
|
+
// ── Shared types ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Compact target summary for TUI display. */
|
|
21
|
+
export interface TuiTargetSummary {
|
|
22
|
+
readonly target_id: string;
|
|
23
|
+
readonly provider_id: string;
|
|
24
|
+
readonly profile: string | undefined;
|
|
25
|
+
readonly state: TargetState;
|
|
26
|
+
readonly health_score: number;
|
|
27
|
+
readonly latency_ema_ms: number;
|
|
28
|
+
readonly enabled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Root state file structure written by server plugin. */
|
|
32
|
+
export interface TuiStateFile {
|
|
33
|
+
readonly version: 1;
|
|
34
|
+
readonly updated_at: number;
|
|
35
|
+
readonly active_target_id: string | undefined;
|
|
36
|
+
readonly targets: readonly TuiTargetSummary[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── File path ─────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const STATE_DIR = join(homedir(), '.local', 'share', 'o-switcher');
|
|
42
|
+
const STATE_FILE = join(STATE_DIR, 'tui-state.json');
|
|
43
|
+
const STATE_TMP = join(STATE_DIR, 'tui-state.json.tmp');
|
|
44
|
+
|
|
45
|
+
/** Resolved path to the TUI state file. Exposed for tests and TUI reader. */
|
|
46
|
+
export const TUI_STATE_PATH = STATE_FILE;
|
|
47
|
+
|
|
48
|
+
// ── Writer (server side) ──────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write state atomically: write to .tmp, then rename.
|
|
52
|
+
* Ensures TUI never reads a partial file.
|
|
53
|
+
*/
|
|
54
|
+
const writeStateAtomic = async (state: TuiStateFile): Promise<void> => {
|
|
55
|
+
await mkdir(dirname(STATE_FILE), { recursive: true });
|
|
56
|
+
const json = JSON.stringify(state);
|
|
57
|
+
await writeFile(STATE_TMP, json, 'utf8');
|
|
58
|
+
await rename(STATE_TMP, STATE_FILE);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a debounced state writer.
|
|
63
|
+
*
|
|
64
|
+
* Collapses rapid state changes into a single file write.
|
|
65
|
+
* Returns a `write(state)` function and a `flush()` for shutdown.
|
|
66
|
+
*/
|
|
67
|
+
export const createStateWriter = (debounceMs = 500): {
|
|
68
|
+
write: (state: TuiStateFile) => void;
|
|
69
|
+
flush: () => Promise<void>;
|
|
70
|
+
} => {
|
|
71
|
+
let pending: TuiStateFile | undefined;
|
|
72
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
let writePromise: Promise<void> = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
const doWrite = (): void => {
|
|
76
|
+
if (!pending) return;
|
|
77
|
+
const snapshot = pending;
|
|
78
|
+
pending = undefined;
|
|
79
|
+
writePromise = writeStateAtomic(snapshot).catch(() => undefined);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const write = (state: TuiStateFile): void => {
|
|
83
|
+
pending = state;
|
|
84
|
+
if (timer) clearTimeout(timer);
|
|
85
|
+
timer = setTimeout(doWrite, debounceMs);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const flush = async (): Promise<void> => {
|
|
89
|
+
if (timer) {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
timer = undefined;
|
|
92
|
+
}
|
|
93
|
+
doWrite();
|
|
94
|
+
await writePromise;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { write, flush };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── Reader (TUI side) ─────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the TUI state file. Returns undefined if missing or corrupt.
|
|
104
|
+
*
|
|
105
|
+
* Uses dynamic import for readFile to avoid bundling it into the server
|
|
106
|
+
* plugin (where only the writer is needed). This eliminates tree-shaking
|
|
107
|
+
* warnings from tsup.
|
|
108
|
+
*/
|
|
109
|
+
export const readTuiState = async (): Promise<TuiStateFile | undefined> => {
|
|
110
|
+
try {
|
|
111
|
+
const { readFile } = await import('node:fs/promises');
|
|
112
|
+
const raw = await readFile(STATE_FILE, 'utf8');
|
|
113
|
+
const parsed = JSON.parse(raw) as TuiStateFile;
|
|
114
|
+
if (parsed.version !== 1) return undefined;
|
|
115
|
+
return parsed;
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/**
|
|
3
|
+
* O-Switcher TUI plugin for OpenCode.
|
|
4
|
+
*
|
|
5
|
+
* Provides:
|
|
6
|
+
* - sidebar_footer: compact one-line status (active profile, health %, target count)
|
|
7
|
+
* - /o-switcher-status route: full dashboard with all targets and their states
|
|
8
|
+
* - /switcher slash command: navigates to the dashboard
|
|
9
|
+
*
|
|
10
|
+
* State is read from a file bridge written by the server plugin (state-bridge.ts).
|
|
11
|
+
* TUI plugins are loaded by Bun with babel-preset-solid transform — this file
|
|
12
|
+
* ships as source .tsx and is compiled at load time.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: This file is excluded from tsconfig typecheck and tsup bundling.
|
|
15
|
+
* It runs in Bun's Solid.js context, not Node.js.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createSignal, onCleanup } from 'solid-js';
|
|
19
|
+
import type {
|
|
20
|
+
TuiPluginModule,
|
|
21
|
+
TuiPluginApi,
|
|
22
|
+
TuiSlotContext,
|
|
23
|
+
} from '@opencode-ai/plugin/tui';
|
|
24
|
+
import { readTuiState } from './state-bridge.js';
|
|
25
|
+
import type { TuiStateFile, TuiTargetSummary } from './state-bridge.js';
|
|
26
|
+
|
|
27
|
+
// ── Constants ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const POLL_INTERVAL_MS = 2000;
|
|
30
|
+
|
|
31
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Format health score as percentage string. */
|
|
34
|
+
const healthPct = (score: number): string => `${Math.round(score * 100)}%`;
|
|
35
|
+
|
|
36
|
+
/** State icon by target state. */
|
|
37
|
+
const stateIcon = (state: TuiTargetSummary['state']): string => {
|
|
38
|
+
const icons: Record<TuiTargetSummary['state'], string> = {
|
|
39
|
+
Active: '●',
|
|
40
|
+
CoolingDown: '◐',
|
|
41
|
+
ReauthRequired: '⚠',
|
|
42
|
+
PolicyBlocked: '✕',
|
|
43
|
+
CircuitOpen: '○',
|
|
44
|
+
CircuitHalfOpen: '◑',
|
|
45
|
+
Draining: '▽',
|
|
46
|
+
Disabled: '—',
|
|
47
|
+
};
|
|
48
|
+
return icons[state];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Display name: prefer profile, fall back to target_id. */
|
|
52
|
+
const displayName = (t: TuiTargetSummary): string =>
|
|
53
|
+
t.profile ?? t.target_id;
|
|
54
|
+
|
|
55
|
+
// ── State polling hook ────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const useTuiState = () => {
|
|
58
|
+
const [state, setState] = createSignal<TuiStateFile | undefined>();
|
|
59
|
+
|
|
60
|
+
const timer = setInterval(async () => {
|
|
61
|
+
const s = await readTuiState();
|
|
62
|
+
setState(s);
|
|
63
|
+
}, POLL_INTERVAL_MS);
|
|
64
|
+
|
|
65
|
+
// Immediate first read
|
|
66
|
+
readTuiState().then((s) => setState(s));
|
|
67
|
+
|
|
68
|
+
onCleanup(() => clearInterval(timer));
|
|
69
|
+
|
|
70
|
+
return state;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ── Components ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Sidebar footer: compact one-line status indicator. */
|
|
76
|
+
const SidebarFooter = () => {
|
|
77
|
+
const state = useTuiState();
|
|
78
|
+
|
|
79
|
+
const label = (): string => {
|
|
80
|
+
const s = state();
|
|
81
|
+
if (!s || s.targets.length === 0) return '◌ o-switcher: no targets';
|
|
82
|
+
|
|
83
|
+
const activeTargets = s.targets.filter(
|
|
84
|
+
(t) => t.enabled && t.state === 'Active',
|
|
85
|
+
);
|
|
86
|
+
const totalEnabled = s.targets.filter((t) => t.enabled).length;
|
|
87
|
+
|
|
88
|
+
// Show the current active target name + health
|
|
89
|
+
const current = s.active_target_id
|
|
90
|
+
? s.targets.find((t) => t.target_id === s.active_target_id)
|
|
91
|
+
: activeTargets[0];
|
|
92
|
+
|
|
93
|
+
if (!current) {
|
|
94
|
+
return `○ o-switcher: 0/${totalEnabled} active`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const name = displayName(current);
|
|
98
|
+
const health = healthPct(current.health_score);
|
|
99
|
+
return `◉ ${name} (${health}) | ${activeTargets.length}/${totalEnabled} active`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return <text truncate={true}>{label()}</text>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Full status dashboard route. */
|
|
106
|
+
const StatusDashboard = () => {
|
|
107
|
+
const state = useTuiState();
|
|
108
|
+
|
|
109
|
+
const content = (): string => {
|
|
110
|
+
const s = state();
|
|
111
|
+
if (!s) return ' Loading O-Switcher state...';
|
|
112
|
+
if (s.targets.length === 0) return ' No targets configured.';
|
|
113
|
+
|
|
114
|
+
const lines: string[] = [
|
|
115
|
+
' O-Switcher Status',
|
|
116
|
+
' ' + '─'.repeat(50),
|
|
117
|
+
'',
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// Header row
|
|
121
|
+
lines.push(
|
|
122
|
+
' ' +
|
|
123
|
+
'Target'.padEnd(24) +
|
|
124
|
+
'State'.padEnd(14) +
|
|
125
|
+
'Health'.padEnd(10) +
|
|
126
|
+
'Latency',
|
|
127
|
+
);
|
|
128
|
+
lines.push(' ' + '─'.repeat(50));
|
|
129
|
+
|
|
130
|
+
for (const t of s.targets) {
|
|
131
|
+
const icon = stateIcon(t.state);
|
|
132
|
+
const name = displayName(t).slice(0, 22);
|
|
133
|
+
const stateStr = `${icon} ${t.state}`;
|
|
134
|
+
const health = healthPct(t.health_score);
|
|
135
|
+
const latency = t.latency_ema_ms > 0 ? `${Math.round(t.latency_ema_ms)}ms` : '—';
|
|
136
|
+
const activeMarker = t.target_id === s.active_target_id ? ' ◀' : '';
|
|
137
|
+
const disabledMark = t.enabled ? '' : ' (off)';
|
|
138
|
+
|
|
139
|
+
lines.push(
|
|
140
|
+
' ' +
|
|
141
|
+
(name + disabledMark).padEnd(24) +
|
|
142
|
+
stateStr.padEnd(14) +
|
|
143
|
+
health.padEnd(10) +
|
|
144
|
+
latency +
|
|
145
|
+
activeMarker,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Summary
|
|
150
|
+
const activeCount = s.targets.filter(
|
|
151
|
+
(t) => t.enabled && t.state === 'Active',
|
|
152
|
+
).length;
|
|
153
|
+
const totalEnabled = s.targets.filter((t) => t.enabled).length;
|
|
154
|
+
const avgHealth =
|
|
155
|
+
totalEnabled > 0
|
|
156
|
+
? s.targets
|
|
157
|
+
.filter((t) => t.enabled)
|
|
158
|
+
.reduce((sum, t) => sum + t.health_score, 0) / totalEnabled
|
|
159
|
+
: 0;
|
|
160
|
+
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(' ' + '─'.repeat(50));
|
|
163
|
+
lines.push(
|
|
164
|
+
` ${activeCount}/${totalEnabled} targets active | avg health: ${healthPct(avgHealth)}`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const age = Date.now() - s.updated_at;
|
|
168
|
+
const ageSec = Math.round(age / 1000);
|
|
169
|
+
lines.push(` Updated ${ageSec}s ago`);
|
|
170
|
+
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return <text wrapMode="none">{content()}</text>;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ── Plugin entry point ────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const tui = async (api: TuiPluginApi) => {
|
|
180
|
+
// Register sidebar footer slot
|
|
181
|
+
api.slots.register({
|
|
182
|
+
slots: {
|
|
183
|
+
sidebar_footer: (_ctx: TuiSlotContext, _props: { session_id: string }) => (
|
|
184
|
+
<SidebarFooter />
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Register status dashboard route
|
|
190
|
+
api.route.register([
|
|
191
|
+
{
|
|
192
|
+
name: 'o-switcher-status',
|
|
193
|
+
render: () => <StatusDashboard />,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
// Register /switcher slash command
|
|
198
|
+
api.command.register(() => [
|
|
199
|
+
{
|
|
200
|
+
title: 'O-Switcher Status',
|
|
201
|
+
value: 'o-switcher-status',
|
|
202
|
+
description: 'Show target health and routing status',
|
|
203
|
+
slash: {
|
|
204
|
+
name: 'switcher',
|
|
205
|
+
aliases: ['osw'],
|
|
206
|
+
},
|
|
207
|
+
onSelect: () => {
|
|
208
|
+
api.route.navigate('o-switcher-status');
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
]);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const tuiModule: TuiPluginModule & { id: string } = {
|
|
215
|
+
id: 'o-switcher',
|
|
216
|
+
tui,
|
|
217
|
+
};
|
|
218
|
+
export default tuiModule;
|