@cardinal4/opencode-tavily-quota 1.0.0 → 1.0.2
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/README.md +36 -25
- package/dist/index.js +31 -0
- package/dist/rpc.js +54 -0
- package/dist/tui.js +185 -0
- package/{usage.ts → dist/usage.js} +83 -125
- package/package.json +16 -24
- package/index.ts +0 -23
- package/tui.tsx +0 -197
package/README.md
CHANGED
|
@@ -26,13 +26,17 @@ polls every 10 minutes and can be refreshed on demand with the command below.
|
|
|
26
26
|
## Requirements
|
|
27
27
|
|
|
28
28
|
- OpenCode 2 (`opencode --version` reports `2.x`).
|
|
29
|
-
- A Tavily API key, supplied
|
|
30
|
-
- `TAVILY_API_KEY` in the environment
|
|
31
|
-
- the `apiKey` plugin option configured
|
|
32
|
-
|
|
33
|
-
`
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
- A Tavily API key, supplied by:
|
|
30
|
+
- `TAVILY_API_KEY` in the CLI environment,
|
|
31
|
+
- the `apiKey` plugin option configured in `cli.json`, or
|
|
32
|
+
- OpenCode's active Tavily connection (use `opencode auth login tavily` or
|
|
33
|
+
`/connect` to connect Tavily).
|
|
34
|
+
|
|
35
|
+
The CLI environment takes precedence over `apiKey`; OpenCode's active
|
|
36
|
+
connection is used when neither supplies a key. The server resolves its saved
|
|
37
|
+
credential and fetches `/usage` itself. Only quota data returns to the TUI;
|
|
38
|
+
the saved key is never sent over plugin RPC or displayed. With a remote server,
|
|
39
|
+
install this plugin there too to use its active connection.
|
|
36
40
|
|
|
37
41
|
The `apiKey` option also accepts `{file:...}` and `{env:...}` references, which
|
|
38
42
|
the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
|
|
@@ -40,27 +44,35 @@ the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
|
|
|
40
44
|
## Layout
|
|
41
45
|
|
|
42
46
|
```
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
index.ts # server entrypoint: integration credential + quota RPC
|
|
48
|
+
rpc.ts # shared RPC definition
|
|
49
|
+
tui.tsx # TUI entrypoint: sidebar slot + command
|
|
50
|
+
usage.ts # key resolution, /usage fetch, formatting
|
|
51
|
+
usage.test.ts # unit tests for usage.ts
|
|
52
|
+
scripts/build.mjs
|
|
53
|
+
dist/ # built entrypoints shipped to npm
|
|
48
54
|
```
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(`
|
|
56
|
+
`npm run build` compiles the TypeScript/JSX sources into `dist/`, which is what
|
|
57
|
+
`package.json` exports and what npm publishes. Published plugins ship built
|
|
58
|
+
`dist/*.js`: OpenCode transpiles `.tsx` entrypoints as it loads them, and the
|
|
59
|
+
Solid JSX runtime (`@opentui/solid`) is only provided at runtime, not from the
|
|
60
|
+
installed plugin.
|
|
61
|
+
|
|
62
|
+
OpenCode installs package plugins under `~/.cache/opencode/npm/`. The server
|
|
63
|
+
entrypoint (`dist/index.js`) registers the quota RPC; the `tui` entrypoint
|
|
64
|
+
(`dist/tui.js`) renders the sidebar in the CLI.
|
|
54
65
|
|
|
55
66
|
## Configuration
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
If you already connected Tavily in OpenCode, no key option is needed. To pass
|
|
69
|
+
the API key as a plugin option instead, register the plugin in `cli.json`:
|
|
58
70
|
|
|
59
71
|
```json title="cli.json"
|
|
60
72
|
{
|
|
61
73
|
"plugins": [
|
|
62
74
|
{
|
|
63
|
-
"package": "
|
|
75
|
+
"package": "@cardinal4/opencode-tavily-quota@latest",
|
|
64
76
|
"options": {
|
|
65
77
|
"apiKey": "tvly-..."
|
|
66
78
|
}
|
|
@@ -84,7 +96,7 @@ reference as `apiKey` to avoid pasting the raw key into `cli.json`:
|
|
|
84
96
|
{
|
|
85
97
|
"plugins": [
|
|
86
98
|
{
|
|
87
|
-
"package": "
|
|
99
|
+
"package": "@cardinal4/opencode-tavily-quota@latest",
|
|
88
100
|
"options": {
|
|
89
101
|
"apiKey": "{file:~/.secrets/tavily-api-key}"
|
|
90
102
|
}
|
|
@@ -116,19 +128,18 @@ therefore handled.
|
|
|
116
128
|
## Development
|
|
117
129
|
|
|
118
130
|
`usage.ts` is deliberately free of OpenCode and OpenTUI imports so it can be
|
|
119
|
-
tested anywhere.
|
|
120
|
-
|
|
121
|
-
error branch using an injected `fetch`.
|
|
131
|
+
tested anywhere. The tests cover quota math, local-key precedence and fallback,
|
|
132
|
+
the connected-key RPC, and HTTP error handling using a mock `fetch`.
|
|
122
133
|
|
|
123
134
|
Run the tests and type check from the repository root:
|
|
124
135
|
|
|
125
136
|
```sh
|
|
126
137
|
npm install
|
|
127
|
-
npm test
|
|
138
|
+
npm test
|
|
128
139
|
npm run typecheck
|
|
129
140
|
```
|
|
130
141
|
|
|
131
142
|
## Notes
|
|
132
143
|
|
|
133
|
-
- The
|
|
134
|
-
|
|
144
|
+
- The server handles OpenCode-connected credentials and their quota requests;
|
|
145
|
+
the CLI handles local keys and renders TUI slots.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server entrypoint. Resolve the active Tavily integration credential and
|
|
3
|
+
* fetch quota here; only the quota result crosses the RPC boundary to the TUI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Plugin } from "@opencode/plugin";
|
|
7
|
+
import { TavilyQuotaRpc } from "./rpc.js";
|
|
8
|
+
import { fetchQuota } from "./usage.js";
|
|
9
|
+
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
10
|
+
export default Plugin.define({
|
|
11
|
+
id: PLUGIN_ID,
|
|
12
|
+
async setup(ctx) {
|
|
13
|
+
await ctx.rpc.register(TavilyQuotaRpc, {
|
|
14
|
+
usage: async () => {
|
|
15
|
+
const connection = await ctx.integration.connection.active("tavily");
|
|
16
|
+
const credential = connection && (await ctx.integration.connection.resolve(connection));
|
|
17
|
+
const key = credential?.type === "key" ? credential.key : connection?.type === "env" ? process.env[connection.name] : undefined;
|
|
18
|
+
if (!key) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: "no active Tavily connection (run opencode auth login tavily)"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return fetchQuota({
|
|
25
|
+
apiKey: key,
|
|
26
|
+
env: {}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
2
|
+
|
|
3
|
+
/** The server fetches quota so the saved OpenCode credential never enters the TUI. */
|
|
4
|
+
export const TavilyQuotaRpc = Rpc.define({
|
|
5
|
+
id: "opencode-tavily-quota",
|
|
6
|
+
methods: {
|
|
7
|
+
usage: {
|
|
8
|
+
input: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {},
|
|
11
|
+
additionalProperties: false
|
|
12
|
+
},
|
|
13
|
+
output: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
ok: {
|
|
17
|
+
type: "boolean"
|
|
18
|
+
},
|
|
19
|
+
message: {
|
|
20
|
+
type: "string"
|
|
21
|
+
},
|
|
22
|
+
snapshot: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
planName: {
|
|
26
|
+
type: "string"
|
|
27
|
+
},
|
|
28
|
+
used: {
|
|
29
|
+
type: "number"
|
|
30
|
+
},
|
|
31
|
+
limit: {
|
|
32
|
+
type: "number"
|
|
33
|
+
},
|
|
34
|
+
remaining: {
|
|
35
|
+
type: "number"
|
|
36
|
+
},
|
|
37
|
+
percentRemaining: {
|
|
38
|
+
type: "number"
|
|
39
|
+
},
|
|
40
|
+
updatedAt: {
|
|
41
|
+
type: "number"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
required: ["planName", "used", "limit", "remaining", "percentRemaining", "updatedAt"],
|
|
45
|
+
additionalProperties: false
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ["ok"],
|
|
49
|
+
additionalProperties: false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
events: {}
|
|
54
|
+
});
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
4
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
5
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
6
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
7
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
+
/** @jsxImportSource @opentui/solid */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tavily quota sidebar — an OpenCode 2 TUI plugin.
|
|
12
|
+
*
|
|
13
|
+
* Renders the remaining Tavily API credits in the session sidebar
|
|
14
|
+
* (`sidebar.content`) and exposes a `/tavily-quota` slash command /
|
|
15
|
+
* "Refresh Tavily quota" palette command that re-fetches on demand.
|
|
16
|
+
*
|
|
17
|
+
* The plugin owns no session state: Tavily credits are account-wide, so a
|
|
18
|
+
* single snapshot is shared by every sidebar render and refreshed on a timer.
|
|
19
|
+
* Colors follow the built-in quota sections: the heading uses the default text
|
|
20
|
+
* color and the body (bar included) uses the muted/subdued text color.
|
|
21
|
+
*
|
|
22
|
+
* Authenticate with `TAVILY_API_KEY`, the `apiKey` plugin option in `cli.json`,
|
|
23
|
+
* or OpenCode's active Tavily integration. A saved key is used server-side.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
27
|
+
import { createSignal } from "solid-js";
|
|
28
|
+
import { fetchQuotaWithFallback, formatPercent, formatQuotaLine, formatRemaining } from "./usage.js";
|
|
29
|
+
import { TavilyQuotaRpc } from "./rpc.js";
|
|
30
|
+
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
31
|
+
// Tavily's `/usage` endpoint allows at most 10 requests per 10 minutes, so poll
|
|
32
|
+
// once per window and rely on the `/tavily-quota` command for on-demand reads.
|
|
33
|
+
const REFRESH_INTERVAL_MS = 600_000;
|
|
34
|
+
/**
|
|
35
|
+
* OpenCode 2.0.7 exposes `text.default` / `text.subdued`; later builds renamed
|
|
36
|
+
* the leaves to `text.base` / `text.muted`. Read whichever the running build
|
|
37
|
+
* provides so the plugin matches the built-in quota sections on both.
|
|
38
|
+
*/
|
|
39
|
+
function readThemeColors(context) {
|
|
40
|
+
const text = context.theme.text;
|
|
41
|
+
return {
|
|
42
|
+
text: text.default ?? text.base,
|
|
43
|
+
textMuted: text.subdued ?? text.muted
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function buildLines(state) {
|
|
47
|
+
if (state.status === "loading") {
|
|
48
|
+
return ["loading…"];
|
|
49
|
+
}
|
|
50
|
+
if (state.status === "error") {
|
|
51
|
+
return [`⚠ ${state.message}`];
|
|
52
|
+
}
|
|
53
|
+
const snapshot = state.snapshot;
|
|
54
|
+
return [formatQuotaLine(snapshot)];
|
|
55
|
+
}
|
|
56
|
+
function TavilyQuotaSidebar(props) {
|
|
57
|
+
const colors = () => readThemeColors(props.context);
|
|
58
|
+
// Keep the quota line on one line; let an error message wrap instead of
|
|
59
|
+
// truncating it, since it can be longer than the sidebar.
|
|
60
|
+
const wrapMode = () => props.state().status === "error" ? "word" : "none";
|
|
61
|
+
return (() => {
|
|
62
|
+
var _el$ = _$createElement("box"),
|
|
63
|
+
_el$2 = _$createElement("text"),
|
|
64
|
+
_el$3 = _$createElement("b");
|
|
65
|
+
_$insertNode(_el$, _el$2);
|
|
66
|
+
_$setProp(_el$, "gap", 0);
|
|
67
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
68
|
+
_$insertNode(_el$2, _el$3);
|
|
69
|
+
_$insertNode(_el$3, _$createTextNode(`Tavily`));
|
|
70
|
+
_$insert(_el$, () => buildLines(props.state()).map(line => (() => {
|
|
71
|
+
var _el$5 = _$createElement("text");
|
|
72
|
+
_$insert(_el$5, line);
|
|
73
|
+
_$effect(_p$ => {
|
|
74
|
+
var _v$ = colors().textMuted,
|
|
75
|
+
_v$2 = wrapMode();
|
|
76
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e));
|
|
77
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "wrapMode", _v$2, _p$.t));
|
|
78
|
+
return _p$;
|
|
79
|
+
}, {
|
|
80
|
+
e: undefined,
|
|
81
|
+
t: undefined
|
|
82
|
+
});
|
|
83
|
+
return _el$5;
|
|
84
|
+
})()), null);
|
|
85
|
+
_$effect(_$p => _$setProp(_el$2, "fg", colors().text, _$p));
|
|
86
|
+
return _el$;
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
function TavilyQuotaCommands(props) {
|
|
90
|
+
props.context.keymap.layer(() => ({
|
|
91
|
+
mode: "global",
|
|
92
|
+
commands: [{
|
|
93
|
+
id: `${PLUGIN_ID}.refresh`,
|
|
94
|
+
title: "Refresh Tavily quota",
|
|
95
|
+
description: "Re-fetch remaining Tavily API credits and show a summary",
|
|
96
|
+
group: "Tavily",
|
|
97
|
+
palette: true,
|
|
98
|
+
slash: {
|
|
99
|
+
name: "tavily-quota"
|
|
100
|
+
},
|
|
101
|
+
run: async () => {
|
|
102
|
+
const next = await props.refresh();
|
|
103
|
+
const message = next.status === "ready" ? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)}` : next.status === "error" ? next.message : "still loading…";
|
|
104
|
+
props.context.ui.toast.show({
|
|
105
|
+
title: "Tavily quota",
|
|
106
|
+
message,
|
|
107
|
+
variant: next.status === "error" ? "error" : "info"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}]
|
|
111
|
+
}));
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
export const TavilyQuotaTuiPlugin = Plugin.define({
|
|
115
|
+
id: PLUGIN_ID,
|
|
116
|
+
async setup(context) {
|
|
117
|
+
const [state, setState] = createSignal({
|
|
118
|
+
status: "loading"
|
|
119
|
+
});
|
|
120
|
+
const apiKey = typeof context.options.apiKey === "string" ? context.options.apiKey : undefined;
|
|
121
|
+
const remote = context.client.rpc(TavilyQuotaRpc);
|
|
122
|
+
const refreshMs = typeof context.options.refreshMs === "number" && context.options.refreshMs > 0 ? context.options.refreshMs : REFRESH_INTERVAL_MS;
|
|
123
|
+
let disposed = false;
|
|
124
|
+
let inFlight = false;
|
|
125
|
+
const refresh = async () => {
|
|
126
|
+
if (inFlight) return state();
|
|
127
|
+
inFlight = true;
|
|
128
|
+
try {
|
|
129
|
+
const result = await fetchQuotaWithFallback({
|
|
130
|
+
apiKey,
|
|
131
|
+
remote: () => remote.usage({}, {
|
|
132
|
+
location: context.location ?? context.data.location.default()
|
|
133
|
+
})
|
|
134
|
+
});
|
|
135
|
+
if (!disposed) {
|
|
136
|
+
setState(result.ok ? {
|
|
137
|
+
status: "ready",
|
|
138
|
+
snapshot: result.snapshot
|
|
139
|
+
} : {
|
|
140
|
+
status: "error",
|
|
141
|
+
message: result.message
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// `fetchQuota` is written never to throw, but a rejected promise here
|
|
146
|
+
// must not take down the sidebar: surface it inline instead.
|
|
147
|
+
if (!disposed) {
|
|
148
|
+
setState({
|
|
149
|
+
status: "error",
|
|
150
|
+
message: error instanceof Error ? error.message : String(error)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
inFlight = false;
|
|
155
|
+
}
|
|
156
|
+
return state();
|
|
157
|
+
};
|
|
158
|
+
const stopSidebar = context.ui.slot({
|
|
159
|
+
append: "sidebar.content",
|
|
160
|
+
render: () => _$createComponent(TavilyQuotaSidebar, {
|
|
161
|
+
context: context,
|
|
162
|
+
state: state
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// `keymap.layer` reads Solid context, so it must be mounted from a slot
|
|
167
|
+
// component rather than called directly during plugin setup.
|
|
168
|
+
const stopCommands = context.ui.slot({
|
|
169
|
+
append: "app",
|
|
170
|
+
render: () => _$createComponent(TavilyQuotaCommands, {
|
|
171
|
+
context: context,
|
|
172
|
+
refresh: refresh
|
|
173
|
+
})
|
|
174
|
+
});
|
|
175
|
+
void refresh();
|
|
176
|
+
const interval = setInterval(() => void refresh(), refreshMs);
|
|
177
|
+
return () => {
|
|
178
|
+
disposed = true;
|
|
179
|
+
clearInterval(interval);
|
|
180
|
+
stopSidebar();
|
|
181
|
+
stopCommands();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
export default TavilyQuotaTuiPlugin;
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { isAbsolute, join, resolve } from "node:path";
|
|
16
|
-
|
|
17
16
|
export const USAGE_URL = "https://api.tavily.com/usage";
|
|
18
17
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
18
|
|
|
@@ -36,59 +35,31 @@ export const BAR_SEPARATOR = " ";
|
|
|
36
35
|
export const DEFAULT_BAR_WIDTH = SIDEBAR_WIDTH - BAR_SEPARATOR.length - PERCENT_COLUMN_WIDTH;
|
|
37
36
|
|
|
38
37
|
/** Shape of `GET https://api.tavily.com/usage` (all fields optional). */
|
|
39
|
-
export interface TavilyUsagePayload {
|
|
40
|
-
key?: {
|
|
41
|
-
usage?: number | null;
|
|
42
|
-
limit?: number | null;
|
|
43
|
-
} | null;
|
|
44
|
-
account?: {
|
|
45
|
-
current_plan?: string | null;
|
|
46
|
-
plan_usage?: number | null;
|
|
47
|
-
plan_limit?: number | null;
|
|
48
|
-
paygo_usage?: number | null;
|
|
49
|
-
paygo_limit?: number | null;
|
|
50
|
-
} | null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface QuotaSnapshot {
|
|
54
|
-
/** Plan name reported by Tavily, e.g. `Researcher`. */
|
|
55
|
-
planName: string;
|
|
56
|
-
/** Credits consumed this billing cycle. */
|
|
57
|
-
used: number;
|
|
58
|
-
/** Credits included this billing cycle. */
|
|
59
|
-
limit: number;
|
|
60
|
-
/** Credits still available (`limit - used`, never negative). */
|
|
61
|
-
remaining: number;
|
|
62
|
-
/** `remaining / limit` as a percentage in `[0, 100]`. */
|
|
63
|
-
percentRemaining: number;
|
|
64
|
-
/** Milliseconds since epoch when the snapshot was produced. */
|
|
65
|
-
updatedAt: number;
|
|
66
|
-
}
|
|
67
38
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
39
|
+
/** Prefer CLI credentials; ask the server only when the CLI has no key. */
|
|
40
|
+
export async function fetchQuotaWithFallback(options) {
|
|
41
|
+
try {
|
|
42
|
+
const key = resolveApiKey(options.env, options.apiKey, options);
|
|
43
|
+
if (key) return fetchQuota({
|
|
44
|
+
...options,
|
|
45
|
+
apiKey: key,
|
|
46
|
+
env: {}
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
// Preserve the existing inline error for an unreadable {file:...} option.
|
|
50
|
+
return fetchQuota(options);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await options.remote();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
message: `OpenCode Tavily credential unavailable: ${reason}`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
89
61
|
}
|
|
90
|
-
|
|
91
|
-
function finite(value: unknown): number | null {
|
|
62
|
+
function finite(value) {
|
|
92
63
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
93
64
|
}
|
|
94
65
|
|
|
@@ -98,16 +69,6 @@ function finite(value: unknown): number | null {
|
|
|
98
69
|
* expansion lives here because `cli.json` passes plugin options through
|
|
99
70
|
* verbatim.
|
|
100
71
|
*/
|
|
101
|
-
export interface ExpandOptions {
|
|
102
|
-
/** Environment used by `{env:NAME}`. Defaults to `process.env`. */
|
|
103
|
-
env?: Record<string, string | undefined>;
|
|
104
|
-
/** Reads a file as UTF-8. Defaults to `readFileSync`. Injected by tests. */
|
|
105
|
-
readFile?: (path: string) => string;
|
|
106
|
-
/** Home directory for `~/`. Defaults to `os.homedir()`. */
|
|
107
|
-
home?: string;
|
|
108
|
-
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
|
109
|
-
cwd?: string;
|
|
110
|
-
}
|
|
111
72
|
|
|
112
73
|
const OPTION_REFERENCE = /\{(env|file):([^}]+)\}/g;
|
|
113
74
|
|
|
@@ -119,28 +80,21 @@ const OPTION_REFERENCE = /\{(env|file):([^}]+)\}/g;
|
|
|
119
80
|
* directory and relative paths resolve against `cwd`. Unreadable files throw
|
|
120
81
|
* with the offending reference and resolved path.
|
|
121
82
|
*/
|
|
122
|
-
export function expandOptionValue(value
|
|
83
|
+
export function expandOptionValue(value, options = {}) {
|
|
123
84
|
const env = options.env ?? process.env;
|
|
124
|
-
const readFile = options.readFile ?? (
|
|
85
|
+
const readFile = options.readFile ?? (path => readFileSync(path, "utf8"));
|
|
125
86
|
const home = options.home ?? homedir();
|
|
126
87
|
const cwd = options.cwd ?? process.cwd();
|
|
127
|
-
|
|
128
|
-
return value.replace(OPTION_REFERENCE, (match, kind: string, body: string) => {
|
|
88
|
+
return value.replace(OPTION_REFERENCE, (match, kind, body) => {
|
|
129
89
|
if (kind === "env") return env[body] ?? "";
|
|
130
|
-
|
|
131
90
|
const raw = body.trim();
|
|
132
|
-
const path =
|
|
133
|
-
raw === "~"
|
|
134
|
-
? home
|
|
135
|
-
: raw.startsWith("~/")
|
|
136
|
-
? join(home, raw.slice(2))
|
|
137
|
-
: isAbsolute(raw)
|
|
138
|
-
? raw
|
|
139
|
-
: resolve(cwd, raw);
|
|
91
|
+
const path = raw === "~" ? home : raw.startsWith("~/") ? join(home, raw.slice(2)) : isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
140
92
|
try {
|
|
141
93
|
return readFile(path).trim();
|
|
142
94
|
} catch (cause) {
|
|
143
|
-
throw new Error(`bad file reference: "${match}" (${path}) could not be read`, {
|
|
95
|
+
throw new Error(`bad file reference: "${match}" (${path}) could not be read`, {
|
|
96
|
+
cause
|
|
97
|
+
});
|
|
144
98
|
}
|
|
145
99
|
});
|
|
146
100
|
}
|
|
@@ -152,16 +106,15 @@ export function expandOptionValue(value: string, options: ExpandOptions = {}): s
|
|
|
152
106
|
* `expandOptionValue`), since `cli.json` does not expand them itself. Never
|
|
153
107
|
* logs or returns the key beyond the caller.
|
|
154
108
|
*/
|
|
155
|
-
export function resolveApiKey(
|
|
156
|
-
env: Record<string, string | undefined> = process.env,
|
|
157
|
-
apiKey?: string,
|
|
158
|
-
options: Omit<ExpandOptions, "env"> = {},
|
|
159
|
-
): string {
|
|
109
|
+
export function resolveApiKey(env = process.env, apiKey, options = {}) {
|
|
160
110
|
const fromEnv = (env.TAVILY_API_KEY ?? "").trim();
|
|
161
111
|
if (fromEnv) return fromEnv;
|
|
162
112
|
const raw = (apiKey ?? "").trim();
|
|
163
113
|
if (!raw) return "";
|
|
164
|
-
return expandOptionValue(raw, {
|
|
114
|
+
return expandOptionValue(raw, {
|
|
115
|
+
...options,
|
|
116
|
+
env
|
|
117
|
+
}).trim();
|
|
165
118
|
}
|
|
166
119
|
|
|
167
120
|
/**
|
|
@@ -171,32 +124,24 @@ export function resolveApiKey(
|
|
|
171
124
|
* is often `null` while the account still has a monthly allowance. Falls back
|
|
172
125
|
* to the key limit when no positive plan limit is reported.
|
|
173
126
|
*/
|
|
174
|
-
export function deriveSnapshot(
|
|
175
|
-
payload: TavilyUsagePayload,
|
|
176
|
-
now: number = Date.now(),
|
|
177
|
-
): QuotaResult {
|
|
127
|
+
export function deriveSnapshot(payload, now = Date.now()) {
|
|
178
128
|
const account = payload.account ?? {};
|
|
179
129
|
const key = payload.key ?? {};
|
|
180
|
-
|
|
181
130
|
const planLimit = finite(account.plan_limit);
|
|
182
131
|
const planUsage = finite(account.plan_usage);
|
|
183
132
|
const keyLimit = finite(key.limit);
|
|
184
133
|
const keyUsage = finite(key.usage);
|
|
185
|
-
|
|
186
134
|
const usePlan = planLimit !== null && planLimit > 0;
|
|
187
135
|
const limit = usePlan ? planLimit : keyLimit;
|
|
188
136
|
const used = usePlan ? planUsage ?? 0 : keyUsage ?? 0;
|
|
189
|
-
|
|
190
137
|
if (limit === null || limit <= 0) {
|
|
191
|
-
return {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
message: "Tavily returned no credit limit for this key"
|
|
141
|
+
};
|
|
192
142
|
}
|
|
193
|
-
|
|
194
143
|
const remaining = Math.max(0, limit - used);
|
|
195
|
-
const planName =
|
|
196
|
-
typeof account.current_plan === "string" && account.current_plan.trim()
|
|
197
|
-
? account.current_plan.trim()
|
|
198
|
-
: "Tavily";
|
|
199
|
-
|
|
144
|
+
const planName = typeof account.current_plan === "string" && account.current_plan.trim() ? account.current_plan.trim() : "Tavily";
|
|
200
145
|
return {
|
|
201
146
|
ok: true,
|
|
202
147
|
snapshot: {
|
|
@@ -204,23 +149,23 @@ export function deriveSnapshot(
|
|
|
204
149
|
used,
|
|
205
150
|
limit,
|
|
206
151
|
remaining,
|
|
207
|
-
percentRemaining:
|
|
208
|
-
updatedAt: now
|
|
209
|
-
}
|
|
152
|
+
percentRemaining: remaining / limit * 100,
|
|
153
|
+
updatedAt: now
|
|
154
|
+
}
|
|
210
155
|
};
|
|
211
156
|
}
|
|
212
157
|
|
|
213
158
|
/** Format a remaining percentage for the sidebar. */
|
|
214
|
-
export function formatPercent(percentRemaining
|
|
159
|
+
export function formatPercent(percentRemaining) {
|
|
215
160
|
const clamped = Math.max(0, Math.min(100, percentRemaining));
|
|
216
161
|
return `${clamped.toFixed(clamped >= 10 ? 0 : 1)}%`;
|
|
217
162
|
}
|
|
218
163
|
|
|
219
164
|
/** Render a fixed-width progress bar for a remaining percentage. */
|
|
220
|
-
export function progressBar(percentRemaining
|
|
165
|
+
export function progressBar(percentRemaining, width = DEFAULT_BAR_WIDTH) {
|
|
221
166
|
const clamped = Math.max(0, Math.min(100, percentRemaining));
|
|
222
167
|
const safeWidth = Math.max(1, Math.floor(width));
|
|
223
|
-
const filled = Math.round(
|
|
168
|
+
const filled = Math.round(clamped / 100 * safeWidth);
|
|
224
169
|
return "█".repeat(filled) + "░".repeat(Math.max(0, safeWidth - filled));
|
|
225
170
|
}
|
|
226
171
|
|
|
@@ -231,16 +176,13 @@ export function progressBar(percentRemaining: number, width = DEFAULT_BAR_WIDTH)
|
|
|
231
176
|
* same right-aligned percent column, so the two sections line up. The exact
|
|
232
177
|
* credit count is still available from `formatRemaining` (used by the toast).
|
|
233
178
|
*/
|
|
234
|
-
export function formatQuotaLine(
|
|
235
|
-
snapshot: QuotaSnapshot,
|
|
236
|
-
width = DEFAULT_BAR_WIDTH,
|
|
237
|
-
): string {
|
|
179
|
+
export function formatQuotaLine(snapshot, width = DEFAULT_BAR_WIDTH) {
|
|
238
180
|
const label = `${formatPercent(snapshot.percentRemaining)} left`;
|
|
239
181
|
return `${progressBar(snapshot.percentRemaining, width)}${BAR_SEPARATOR}${label.padStart(PERCENT_COLUMN_WIDTH)}`;
|
|
240
182
|
}
|
|
241
183
|
|
|
242
184
|
/** Format the headline line, e.g. `839 credits left`. */
|
|
243
|
-
export function formatRemaining(snapshot
|
|
185
|
+
export function formatRemaining(snapshot) {
|
|
244
186
|
return `${snapshot.remaining} credits left`;
|
|
245
187
|
}
|
|
246
188
|
|
|
@@ -250,50 +192,66 @@ export function formatRemaining(snapshot: QuotaSnapshot): string {
|
|
|
250
192
|
* Never throws: every failure is returned as `{ ok: false, message }` so the
|
|
251
193
|
* sidebar can render it inline.
|
|
252
194
|
*/
|
|
253
|
-
export async function fetchQuota(options
|
|
254
|
-
let key
|
|
195
|
+
export async function fetchQuota(options = {}) {
|
|
196
|
+
let key;
|
|
255
197
|
try {
|
|
256
198
|
key = resolveApiKey(options.env, options.apiKey, options);
|
|
257
199
|
} catch (error) {
|
|
258
200
|
// A `{file:...}` option can point at a missing or unreadable file; surface
|
|
259
201
|
// that inline instead of rejecting the sidebar render.
|
|
260
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: error instanceof Error ? error.message : String(error)
|
|
205
|
+
};
|
|
261
206
|
}
|
|
262
207
|
if (!key) {
|
|
263
208
|
return {
|
|
264
209
|
ok: false,
|
|
265
|
-
message: "no Tavily API key (set TAVILY_API_KEY or the apiKey plugin option)"
|
|
210
|
+
message: "no Tavily API key (set TAVILY_API_KEY or the apiKey plugin option)"
|
|
266
211
|
};
|
|
267
212
|
}
|
|
268
|
-
|
|
269
213
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
270
|
-
let response
|
|
214
|
+
let response;
|
|
271
215
|
try {
|
|
272
216
|
response = await fetchImpl(USAGE_URL, {
|
|
273
|
-
headers: {
|
|
274
|
-
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${key}`
|
|
219
|
+
},
|
|
220
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
275
221
|
});
|
|
276
222
|
} catch (error) {
|
|
277
223
|
const reason = error instanceof Error ? error.message : String(error);
|
|
278
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
message: `request failed: ${reason}`
|
|
227
|
+
};
|
|
279
228
|
}
|
|
280
|
-
|
|
281
229
|
if (response.status === 401) {
|
|
282
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
message: "API key rejected (401)"
|
|
233
|
+
};
|
|
283
234
|
}
|
|
284
235
|
if (response.status === 429) {
|
|
285
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
message: "rate limited on /usage (10 req / 10 min)"
|
|
239
|
+
};
|
|
286
240
|
}
|
|
287
241
|
if (!response.ok) {
|
|
288
|
-
return {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
message: `HTTP ${response.status} from /usage`
|
|
245
|
+
};
|
|
289
246
|
}
|
|
290
|
-
|
|
291
|
-
let payload: TavilyUsagePayload;
|
|
247
|
+
let payload;
|
|
292
248
|
try {
|
|
293
|
-
payload =
|
|
249
|
+
payload = await response.json();
|
|
294
250
|
} catch {
|
|
295
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
message: "invalid JSON from /usage"
|
|
254
|
+
};
|
|
296
255
|
}
|
|
297
|
-
|
|
298
256
|
return deriveSnapshot(payload, options.now);
|
|
299
257
|
}
|
package/package.json
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@cardinal4/opencode-tavily-quota",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.2",
|
|
5
5
|
"description": "OpenCode 2 TUI plugin that shows remaining Tavily API credits in the sidebar.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
|
+
"main": "./dist/index.js",
|
|
8
9
|
"exports": {
|
|
9
|
-
".": "./index.
|
|
10
|
-
"./server": "./index.
|
|
11
|
-
"./tui": "./tui.
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./server": "./dist/index.js",
|
|
12
|
+
"./tui": "./dist/tui.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
|
-
"
|
|
15
|
-
"tui.tsx",
|
|
16
|
-
"usage.ts",
|
|
15
|
+
"dist",
|
|
17
16
|
"README.md",
|
|
18
17
|
"LICENSE"
|
|
19
18
|
],
|
|
20
19
|
"scripts": {
|
|
21
|
-
"
|
|
20
|
+
"build": "node scripts/build.mjs",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"test": "tsx --test usage.test.ts index.test.ts",
|
|
22
23
|
"typecheck": "tsc --noEmit"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
@@ -37,26 +38,17 @@
|
|
|
37
38
|
"url": "https://github.com/cardin/opencode-tavily-quota/issues"
|
|
38
39
|
},
|
|
39
40
|
"homepage": "https://github.com/cardin/opencode-tavily-quota#readme",
|
|
40
|
-
"
|
|
41
|
-
"@opencode/plugin": "^2.0.7",
|
|
42
|
-
"@opentui/core": ">=0.5.10",
|
|
43
|
-
"@opentui/solid": ">=0.5.10",
|
|
44
|
-
"solid-js": ">=1.9.0"
|
|
45
|
-
},
|
|
46
|
-
"peerDependenciesMeta": {
|
|
47
|
-
"@opentui/core": {
|
|
48
|
-
"optional": true
|
|
49
|
-
},
|
|
50
|
-
"@opentui/solid": {
|
|
51
|
-
"optional": true
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
"devDependencies": {
|
|
41
|
+
"dependencies": {
|
|
55
42
|
"@opencode/plugin": "^2.0.7",
|
|
56
43
|
"@opentui/core": "^0.5.11",
|
|
57
44
|
"@opentui/solid": "^0.5.11",
|
|
45
|
+
"solid-js": "^1.9.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@babel/core": "^7.28.0",
|
|
49
|
+
"@babel/preset-typescript": "^7.27.1",
|
|
58
50
|
"@types/node": "^22.0.0",
|
|
59
|
-
"solid
|
|
51
|
+
"babel-preset-solid": "^1.9.12",
|
|
60
52
|
"tsx": "^4.20.0",
|
|
61
53
|
"typescript": "^5.6.0"
|
|
62
54
|
},
|
package/index.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server entrypoint for the Tavily quota sidebar plugin.
|
|
3
|
-
*
|
|
4
|
-
* OpenCode discovers a local plugin directory by its `index` (server) entry and
|
|
5
|
-
* loads the `tui` entry beside it for the terminal UI. All rendering happens in
|
|
6
|
-
* the CLI runtime (`tui.tsx`); this file only exists so the directory is a
|
|
7
|
-
* complete plugin and shows up by id instead of as an anonymous entry.
|
|
8
|
-
*
|
|
9
|
-
* It deliberately imports nothing beyond `@opencode/plugin` so the background
|
|
10
|
-
* service can load it in any runtime.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { Plugin } from "@opencode/plugin";
|
|
14
|
-
|
|
15
|
-
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
16
|
-
|
|
17
|
-
export default Plugin.define({
|
|
18
|
-
id: PLUGIN_ID,
|
|
19
|
-
setup() {
|
|
20
|
-
// Intentional no-op: the sidebar and command are registered by the TUI
|
|
21
|
-
// entrypoint (tui.tsx), which runs in the CLI process.
|
|
22
|
-
},
|
|
23
|
-
});
|
package/tui.tsx
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Tavily quota sidebar — an OpenCode 2 TUI plugin.
|
|
5
|
-
*
|
|
6
|
-
* Renders the remaining Tavily API credits in the session sidebar
|
|
7
|
-
* (`sidebar.content`) and exposes a `/tavily-quota` slash command /
|
|
8
|
-
* "Refresh Tavily quota" palette command that re-fetches on demand.
|
|
9
|
-
*
|
|
10
|
-
* The plugin owns no session state: Tavily credits are account-wide, so a
|
|
11
|
-
* single snapshot is shared by every sidebar render and refreshed on a timer.
|
|
12
|
-
* Colors follow the built-in quota sections: the heading uses the default text
|
|
13
|
-
* color and the body (bar included) uses the muted/subdued text color.
|
|
14
|
-
*
|
|
15
|
-
* Authenticate with `TAVILY_API_KEY`, or with the `apiKey` plugin option
|
|
16
|
-
* configured on this plugin in `cli.json`. Resolution and the network call
|
|
17
|
-
* live in `./usage.ts`.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { RGBA } from "@opentui/core";
|
|
21
|
-
import { Plugin } from "@opencode/plugin/tui";
|
|
22
|
-
import { createSignal } from "solid-js";
|
|
23
|
-
import {
|
|
24
|
-
fetchQuota,
|
|
25
|
-
formatPercent,
|
|
26
|
-
formatQuotaLine,
|
|
27
|
-
formatRemaining,
|
|
28
|
-
type QuotaSnapshot,
|
|
29
|
-
} from "./usage";
|
|
30
|
-
|
|
31
|
-
type Context = Plugin.Context;
|
|
32
|
-
|
|
33
|
-
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
34
|
-
// Tavily's `/usage` endpoint allows at most 10 requests per 10 minutes, so poll
|
|
35
|
-
// once per window and rely on the `/tavily-quota` command for on-demand reads.
|
|
36
|
-
const REFRESH_INTERVAL_MS = 600_000;
|
|
37
|
-
|
|
38
|
-
type ViewState =
|
|
39
|
-
| { status: "loading" }
|
|
40
|
-
| { status: "ready"; snapshot: QuotaSnapshot }
|
|
41
|
-
| { status: "error"; message: string };
|
|
42
|
-
|
|
43
|
-
type ThemeColors = {
|
|
44
|
-
text?: RGBA;
|
|
45
|
-
textMuted?: RGBA;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* OpenCode 2.0.7 exposes `text.default` / `text.subdued`; later builds renamed
|
|
50
|
-
* the leaves to `text.base` / `text.muted`. Read whichever the running build
|
|
51
|
-
* provides so the plugin matches the built-in quota sections on both.
|
|
52
|
-
*/
|
|
53
|
-
function readThemeColors(context: Context): ThemeColors {
|
|
54
|
-
const text = context.theme.text as unknown as {
|
|
55
|
-
default?: RGBA;
|
|
56
|
-
base?: RGBA;
|
|
57
|
-
subdued?: RGBA;
|
|
58
|
-
muted?: RGBA;
|
|
59
|
-
};
|
|
60
|
-
return {
|
|
61
|
-
text: text.default ?? text.base,
|
|
62
|
-
textMuted: text.subdued ?? text.muted,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function buildLines(state: ViewState): string[] {
|
|
67
|
-
if (state.status === "loading") {
|
|
68
|
-
return ["loading…"];
|
|
69
|
-
}
|
|
70
|
-
if (state.status === "error") {
|
|
71
|
-
return [`⚠ ${state.message}`];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const snapshot = state.snapshot;
|
|
75
|
-
return [formatQuotaLine(snapshot)];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function TavilyQuotaSidebar(props: { context: Context; state: () => ViewState }) {
|
|
79
|
-
const colors = () => readThemeColors(props.context);
|
|
80
|
-
// Keep the quota line on one line; let an error message wrap instead of
|
|
81
|
-
// truncating it, since it can be longer than the sidebar.
|
|
82
|
-
const wrapMode = () => (props.state().status === "error" ? "word" : "none");
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<box gap={0} flexDirection="column">
|
|
86
|
-
<text fg={colors().text}>
|
|
87
|
-
<b>Tavily</b>
|
|
88
|
-
</text>
|
|
89
|
-
{buildLines(props.state()).map((line) => (
|
|
90
|
-
<text fg={colors().textMuted} wrapMode={wrapMode()}>
|
|
91
|
-
{line}
|
|
92
|
-
</text>
|
|
93
|
-
))}
|
|
94
|
-
</box>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function TavilyQuotaCommands(props: {
|
|
99
|
-
context: Context;
|
|
100
|
-
refresh: () => Promise<ViewState>;
|
|
101
|
-
}) {
|
|
102
|
-
props.context.keymap.layer(() => ({
|
|
103
|
-
mode: "global",
|
|
104
|
-
commands: [
|
|
105
|
-
{
|
|
106
|
-
id: `${PLUGIN_ID}.refresh`,
|
|
107
|
-
title: "Refresh Tavily quota",
|
|
108
|
-
description: "Re-fetch remaining Tavily API credits and show a summary",
|
|
109
|
-
group: "Tavily",
|
|
110
|
-
palette: true,
|
|
111
|
-
slash: { name: "tavily-quota" },
|
|
112
|
-
run: async () => {
|
|
113
|
-
const next = await props.refresh();
|
|
114
|
-
const message =
|
|
115
|
-
next.status === "ready"
|
|
116
|
-
? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)}`
|
|
117
|
-
: next.status === "error"
|
|
118
|
-
? next.message
|
|
119
|
-
: "still loading…";
|
|
120
|
-
props.context.ui.toast.show({
|
|
121
|
-
title: "Tavily quota",
|
|
122
|
-
message,
|
|
123
|
-
variant: next.status === "error" ? "error" : "info",
|
|
124
|
-
});
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
}));
|
|
129
|
-
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export const TavilyQuotaTuiPlugin = Plugin.define({
|
|
134
|
-
id: PLUGIN_ID,
|
|
135
|
-
async setup(context) {
|
|
136
|
-
const [state, setState] = createSignal<ViewState>({ status: "loading" });
|
|
137
|
-
const apiKey = typeof context.options.apiKey === "string" ? context.options.apiKey : undefined;
|
|
138
|
-
const refreshMs =
|
|
139
|
-
typeof context.options.refreshMs === "number" && context.options.refreshMs > 0
|
|
140
|
-
? context.options.refreshMs
|
|
141
|
-
: REFRESH_INTERVAL_MS;
|
|
142
|
-
|
|
143
|
-
let disposed = false;
|
|
144
|
-
let inFlight = false;
|
|
145
|
-
|
|
146
|
-
const refresh = async (): Promise<ViewState> => {
|
|
147
|
-
if (inFlight) return state();
|
|
148
|
-
inFlight = true;
|
|
149
|
-
try {
|
|
150
|
-
const result = await fetchQuota({ apiKey });
|
|
151
|
-
if (!disposed) {
|
|
152
|
-
setState(
|
|
153
|
-
result.ok
|
|
154
|
-
? { status: "ready", snapshot: result.snapshot }
|
|
155
|
-
: { status: "error", message: result.message },
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
} catch (error) {
|
|
159
|
-
// `fetchQuota` is written never to throw, but a rejected promise here
|
|
160
|
-
// must not take down the sidebar: surface it inline instead.
|
|
161
|
-
if (!disposed) {
|
|
162
|
-
setState({
|
|
163
|
-
status: "error",
|
|
164
|
-
message: error instanceof Error ? error.message : String(error),
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
} finally {
|
|
168
|
-
inFlight = false;
|
|
169
|
-
}
|
|
170
|
-
return state();
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const stopSidebar = context.ui.slot({
|
|
174
|
-
append: "sidebar.content",
|
|
175
|
-
render: () => <TavilyQuotaSidebar context={context} state={state} />,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// `keymap.layer` reads Solid context, so it must be mounted from a slot
|
|
179
|
-
// component rather than called directly during plugin setup.
|
|
180
|
-
const stopCommands = context.ui.slot({
|
|
181
|
-
append: "app",
|
|
182
|
-
render: () => <TavilyQuotaCommands context={context} refresh={refresh} />,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
void refresh();
|
|
186
|
-
const interval = setInterval(() => void refresh(), refreshMs);
|
|
187
|
-
|
|
188
|
-
return () => {
|
|
189
|
-
disposed = true;
|
|
190
|
-
clearInterval(interval);
|
|
191
|
-
stopSidebar();
|
|
192
|
-
stopCommands();
|
|
193
|
-
};
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
export default TavilyQuotaTuiPlugin;
|