@aliou/pi-synthetic 0.3.0 → 0.4.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.
@@ -4,6 +4,12 @@ on:
4
4
  push:
5
5
  branches:
6
6
  - main
7
+ workflow_dispatch:
8
+ inputs:
9
+ skip-checks:
10
+ description: "Skip lint and typecheck"
11
+ type: boolean
12
+ default: false
7
13
 
8
14
  concurrency:
9
15
  group: ${{ github.workflow }}-${{ github.ref }}
@@ -11,6 +17,7 @@ concurrency:
11
17
 
12
18
  jobs:
13
19
  check:
20
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip-checks) }}
14
21
  runs-on: ubuntu-latest
15
22
  steps:
16
23
  - uses: actions/checkout@v4
@@ -34,6 +41,7 @@ jobs:
34
41
  publish:
35
42
  name: Publish
36
43
  needs: check
44
+ if: ${{ always() && (needs.check.result == 'success' || needs.check.result == 'skipped') }}
37
45
  runs-on: ubuntu-latest
38
46
  permissions:
39
47
  contents: write
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @aliou/pi-synthetic
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5cca252: Add `/synthetic:quotas` command to display API usage quotas
8
+
9
+ A new slash command that shows your Synthetic API subscription quotas in a rich terminal UI:
10
+
11
+ - Visual usage bar with color-coded severity (green/yellow/red based on usage)
12
+ - Aligned columns showing limit, used, and remaining requests
13
+ - ISO8601 renewal timestamp with relative time formatting (e.g., "in 5 hours")
14
+ - Closes on any key press
15
+
16
+ The command is only registered when `SYNTHETIC_API_KEY` environment variable is set.
17
+
18
+ - a8cacfb: Add Synthetic web search tool
19
+
20
+ New tool `synthetic_web_search` allows agents to search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates.
21
+
22
+ **Note:** Search is a subscription-only feature. The tool will only be registered if the `SYNTHETIC_API_KEY` belongs to an active subscription (verified via the usage endpoint).
23
+
3
24
  ## 0.3.0
4
25
 
5
26
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/aliou/pi-synthetic"
@@ -16,8 +16,9 @@
16
16
  "devDependencies": {
17
17
  "@biomejs/biome": "^2.3.13",
18
18
  "@changesets/cli": "^2.27.11",
19
- "@mariozechner/pi-coding-agent": "^0.50.1",
20
- "@mariozechner/pi-tui": "^0.50.1",
19
+ "@mariozechner/pi-coding-agent": "0.51.0",
20
+ "@mariozechner/pi-tui": "0.51.0",
21
+ "@sinclair/typebox": "^0.34.48",
21
22
  "@types/node": "^25.0.10",
22
23
  "husky": "^9.1.7",
23
24
  "typescript": "^5.9.3"
@@ -0,0 +1,124 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { Component } from "@mariozechner/pi-tui";
3
+ import { QuotasDisplayComponent } from "../components/quotas-display.js";
4
+ import { QuotasErrorComponent } from "../components/quotas-error.js";
5
+ import { QuotasLoadingComponent } from "../components/quotas-loading.js";
6
+ import type { QuotasResponse } from "../types/quotas.js";
7
+
8
+ export function registerQuotasCommand(pi: ExtensionAPI): void {
9
+ pi.registerCommand("synthetic:quotas", {
10
+ description: "Display Synthetic API usage quotas",
11
+ handler: async (_args, ctx) => {
12
+ if (!ctx.hasUI) {
13
+ const quotas = await fetchQuotas();
14
+ if (!quotas) {
15
+ console.error("Failed to fetch quotas");
16
+ return;
17
+ }
18
+ console.log(formatQuotasPlain(quotas));
19
+ return;
20
+ }
21
+
22
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
23
+ let currentComponent: Component = new QuotasLoadingComponent(theme);
24
+
25
+ fetchQuotas()
26
+ .then((quotas) => {
27
+ if (!quotas) {
28
+ currentComponent = new QuotasErrorComponent(
29
+ theme,
30
+ "Failed to fetch quotas",
31
+ );
32
+ } else {
33
+ currentComponent = new QuotasDisplayComponent(theme, quotas);
34
+ }
35
+ tui.requestRender();
36
+ })
37
+ .catch(() => {
38
+ currentComponent = new QuotasErrorComponent(
39
+ theme,
40
+ "Failed to fetch quotas",
41
+ );
42
+ tui.requestRender();
43
+ });
44
+
45
+ return {
46
+ render: (width: number) => currentComponent.render(width),
47
+ invalidate: () => currentComponent.invalidate(),
48
+ handleInput: (_data: string) => {
49
+ done();
50
+ },
51
+ };
52
+ });
53
+ },
54
+ });
55
+ }
56
+
57
+ async function fetchQuotas(): Promise<QuotasResponse | null> {
58
+ const apiKey = process.env.SYNTHETIC_API_KEY;
59
+ if (!apiKey) {
60
+ return null;
61
+ }
62
+
63
+ try {
64
+ const response = await fetch("https://api.synthetic.new/v2/quotas", {
65
+ headers: {
66
+ Authorization: `Bearer ${apiKey}`,
67
+ },
68
+ });
69
+
70
+ if (!response.ok) {
71
+ return null;
72
+ }
73
+
74
+ return (await response.json()) as QuotasResponse;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function formatQuotasPlain(quotas: QuotasResponse): string {
81
+ const remaining = quotas.subscription.limit - quotas.subscription.requests;
82
+ const percentUsed = Math.round(
83
+ (quotas.subscription.requests / quotas.subscription.limit) * 100,
84
+ );
85
+
86
+ return [
87
+ "Synthetic API Quotas",
88
+ "",
89
+ `Usage: ${percentUsed}%`,
90
+ `Limit: ${quotas.subscription.limit.toLocaleString()} requests`,
91
+ `Used: ${quotas.subscription.requests.toLocaleString()} requests`,
92
+ `Remaining: ${remaining.toLocaleString()} requests`,
93
+ "",
94
+ `Renews: ${quotas.subscription.renewsAt} (${formatRelativeTime(new Date(quotas.subscription.renewsAt))})`,
95
+ ].join("\n");
96
+ }
97
+
98
+ function formatRelativeTime(date: Date): string {
99
+ const now = new Date();
100
+ const diffMs = date.getTime() - now.getTime();
101
+
102
+ if (diffMs <= 0) {
103
+ return "renews soon";
104
+ }
105
+
106
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
107
+ const diffMinutes = Math.ceil(diffMs / (1000 * 60));
108
+ const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
109
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
110
+
111
+ if (diffMinutes < 60) {
112
+ return rtf.format(diffMinutes, "minute");
113
+ } else if (diffHours < 24) {
114
+ return rtf.format(diffHours, "hour");
115
+ } else if (diffDays < 30) {
116
+ return rtf.format(diffDays, "day");
117
+ } else if (diffDays < 365) {
118
+ const months = Math.floor(diffDays / 30);
119
+ return rtf.format(months, "month");
120
+ } else {
121
+ const years = Math.floor(diffDays / 365);
122
+ return rtf.format(years, "year");
123
+ }
124
+ }
@@ -0,0 +1,139 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import { type Component, Container, Text } from "@mariozechner/pi-tui";
4
+ import type { QuotasResponse } from "../types/quotas.js";
5
+
6
+ export class QuotasDisplayComponent implements Component {
7
+ private container: Container;
8
+
9
+ constructor(theme: Theme, quotas: QuotasResponse) {
10
+ this.container = new Container();
11
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
12
+
13
+ this.container.addChild(border);
14
+ this.container.addChild(
15
+ new Text(theme.fg("accent", theme.bold(" Synthetic API Quotas ")), 1, 0),
16
+ );
17
+ this.container.addChild(new Text("", 0, 0));
18
+
19
+ const remaining = quotas.subscription.limit - quotas.subscription.requests;
20
+ const percentUsed = Math.round(
21
+ (quotas.subscription.requests / quotas.subscription.limit) * 100,
22
+ );
23
+
24
+ // Usage bar: left side = used (colored by severity), right side = remaining (neutral)
25
+ const barWidth = 40;
26
+ const usedWidth = Math.round((percentUsed / 100) * barWidth);
27
+ const remainingWidth = barWidth - usedWidth;
28
+
29
+ let bar: string;
30
+ if (usedWidth >= barWidth) {
31
+ bar = theme.fg("error", "█".repeat(barWidth));
32
+ } else if (percentUsed > 75) {
33
+ // High usage: used is warning, remaining is dim
34
+ bar =
35
+ theme.fg("warning", "█".repeat(usedWidth)) +
36
+ theme.fg("dim", "█".repeat(remainingWidth));
37
+ } else {
38
+ // Normal usage: used is success, remaining is dim
39
+ bar =
40
+ theme.fg("success", "█".repeat(usedWidth)) +
41
+ theme.fg("dim", "█".repeat(remainingWidth));
42
+ }
43
+
44
+ this.container.addChild(new Text(` ${theme.bold("Usage")}`, 1, 0));
45
+ this.container.addChild(new Text(` ${bar} ${percentUsed}%`, 1, 0));
46
+ this.container.addChild(new Text("", 0, 0));
47
+
48
+ // Numbers - aligned columns
49
+ const limitStr = quotas.subscription.limit.toLocaleString();
50
+ const usedStr = quotas.subscription.requests.toLocaleString();
51
+ const remainingStr = remaining.toLocaleString();
52
+ const maxValueWidth = Math.max(
53
+ limitStr.length,
54
+ usedStr.length,
55
+ remainingStr.length,
56
+ );
57
+
58
+ this.container.addChild(
59
+ new Text(
60
+ ` ${theme.fg("muted", "Limit:")} ${limitStr.padStart(maxValueWidth, " ")} requests`,
61
+ 1,
62
+ 0,
63
+ ),
64
+ );
65
+ this.container.addChild(
66
+ new Text(
67
+ ` ${theme.fg("muted", "Used:")} ${usedStr.padStart(maxValueWidth, " ")} requests`,
68
+ 1,
69
+ 0,
70
+ ),
71
+ );
72
+ this.container.addChild(
73
+ new Text(
74
+ ` ${theme.fg("muted", "Remaining:")} ${theme.fg(
75
+ remaining > 0 ? "success" : "error",
76
+ remainingStr.padStart(maxValueWidth, " "),
77
+ )} requests`,
78
+ 1,
79
+ 0,
80
+ ),
81
+ );
82
+ this.container.addChild(new Text("", 0, 0));
83
+
84
+ // Renewal date - ISO8601 with relative time
85
+ const renewsAt = new Date(quotas.subscription.renewsAt);
86
+ const isoStr = quotas.subscription.renewsAt;
87
+ const relativeStr = formatRelativeTime(renewsAt);
88
+
89
+ this.container.addChild(
90
+ new Text(
91
+ ` ${theme.fg("muted", "Renews:")} ${isoStr} (${relativeStr})`,
92
+ 1,
93
+ 0,
94
+ ),
95
+ );
96
+
97
+ this.container.addChild(new Text("", 0, 0));
98
+ this.container.addChild(
99
+ new Text(theme.fg("dim", " Press any key to close"), 1, 0),
100
+ );
101
+ this.container.addChild(border);
102
+ }
103
+
104
+ render(width: number): string[] {
105
+ return this.container.render(width);
106
+ }
107
+
108
+ invalidate(): void {
109
+ this.container.invalidate();
110
+ }
111
+ }
112
+
113
+ function formatRelativeTime(date: Date): string {
114
+ const now = new Date();
115
+ const diffMs = date.getTime() - now.getTime();
116
+
117
+ if (diffMs <= 0) {
118
+ return "renews soon";
119
+ }
120
+
121
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
122
+ const diffMinutes = Math.ceil(diffMs / (1000 * 60));
123
+ const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
124
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
125
+
126
+ if (diffMinutes < 60) {
127
+ return rtf.format(diffMinutes, "minute");
128
+ } else if (diffHours < 24) {
129
+ return rtf.format(diffHours, "hour");
130
+ } else if (diffDays < 30) {
131
+ return rtf.format(diffDays, "day");
132
+ } else if (diffDays < 365) {
133
+ const months = Math.floor(diffDays / 30);
134
+ return rtf.format(months, "month");
135
+ } else {
136
+ const years = Math.floor(diffDays / 365);
137
+ return rtf.format(years, "year");
138
+ }
139
+ }
@@ -0,0 +1,32 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import { type Component, Container, Text } from "@mariozechner/pi-tui";
4
+
5
+ export class QuotasErrorComponent implements Component {
6
+ private container: Container;
7
+
8
+ constructor(theme: Theme, message: string) {
9
+ this.container = new Container();
10
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
11
+
12
+ this.container.addChild(border);
13
+ this.container.addChild(
14
+ new Text(theme.fg("accent", theme.bold(" Synthetic API Quotas ")), 1, 0),
15
+ );
16
+ this.container.addChild(new Text("", 0, 0));
17
+ this.container.addChild(new Text(theme.fg("error", ` ${message}`), 1, 0));
18
+ this.container.addChild(new Text("", 0, 0));
19
+ this.container.addChild(
20
+ new Text(theme.fg("dim", " Press any key to close"), 1, 0),
21
+ );
22
+ this.container.addChild(border);
23
+ }
24
+
25
+ render(width: number): string[] {
26
+ return this.container.render(width);
27
+ }
28
+
29
+ invalidate(): void {
30
+ this.container.invalidate();
31
+ }
32
+ }
@@ -0,0 +1,30 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import { type Component, Container, Text } from "@mariozechner/pi-tui";
4
+
5
+ export class QuotasLoadingComponent implements Component {
6
+ private container: Container;
7
+
8
+ constructor(theme: Theme) {
9
+ this.container = new Container();
10
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
11
+
12
+ this.container.addChild(border);
13
+ this.container.addChild(
14
+ new Text(theme.fg("accent", theme.bold(" Synthetic API Quotas ")), 1, 0),
15
+ );
16
+ this.container.addChild(new Text("", 0, 0));
17
+ this.container.addChild(
18
+ new Text(theme.fg("dim", " Loading quotas..."), 1, 0),
19
+ );
20
+ this.container.addChild(border);
21
+ }
22
+
23
+ render(width: number): string[] {
24
+ return this.container.render(width);
25
+ }
26
+
27
+ invalidate(): void {
28
+ this.container.invalidate();
29
+ }
30
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerQuotasCommand } from "./commands/quotas.js";
2
3
  import { registerSyntheticProvider } from "./providers/index.js";
4
+ import { registerSyntheticWebSearchTool } from "./tools/search.js";
3
5
 
4
- export default function (pi: ExtensionAPI) {
6
+ export default async function (pi: ExtensionAPI) {
5
7
  registerSyntheticProvider(pi);
8
+
9
+ // Only register quotas command and web search tool if API key is available
10
+ if (process.env.SYNTHETIC_API_KEY) {
11
+ registerQuotasCommand(pi);
12
+ await registerSyntheticWebSearchTool(pi);
13
+ }
6
14
  }
@@ -0,0 +1,243 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ Theme,
6
+ ToolRenderResultOptions,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import { Text } from "@mariozechner/pi-tui";
9
+ import { type Static, Type } from "@sinclair/typebox";
10
+
11
+ // Types
12
+ interface SyntheticSearchResult {
13
+ url: string;
14
+ title: string;
15
+ text: string;
16
+ published: string;
17
+ }
18
+
19
+ interface SyntheticSearchResponse {
20
+ results: SyntheticSearchResult[];
21
+ }
22
+
23
+ interface WebSearchDetails {
24
+ results?: SyntheticSearchResult[];
25
+ query?: string;
26
+ error?: string;
27
+ isError?: boolean;
28
+ }
29
+
30
+ // Schema
31
+ const SearchParams = Type.Object({
32
+ query: Type.String({
33
+ description: "The search query. Be specific for best results.",
34
+ }),
35
+ });
36
+
37
+ type SearchParamsType = Static<typeof SearchParams>;
38
+
39
+ // Check if API key has subscription access by calling usage endpoint
40
+ // Subscription keys have content in the response, usage-based keys return empty array
41
+ async function hasSubscriptionAccess(apiKey: string): Promise<boolean> {
42
+ try {
43
+ const response = await fetch("https://api.synthetic.new/v2/usage", {
44
+ method: "GET",
45
+ headers: {
46
+ Authorization: `Bearer ${apiKey}`,
47
+ },
48
+ });
49
+
50
+ if (!response.ok) {
51
+ return false;
52
+ }
53
+
54
+ const data = await response.json();
55
+ // Subscription keys have content in the response array
56
+ return Array.isArray(data) && data.length > 0;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ // Tool Registration
63
+ export async function registerSyntheticWebSearchTool(pi: ExtensionAPI) {
64
+ // Check for API key
65
+ const apiKey = process.env.SYNTHETIC_API_KEY;
66
+ if (!apiKey) {
67
+ return;
68
+ }
69
+
70
+ // Only register if user has subscription access (search is subscription-only)
71
+ const hasAccess = await hasSubscriptionAccess(apiKey);
72
+ if (!hasAccess) {
73
+ return;
74
+ }
75
+
76
+ pi.registerTool<typeof SearchParams, WebSearchDetails>({
77
+ name: "synthetic_web_search",
78
+ label: "Synthetic: Web Search",
79
+ description:
80
+ "Search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates. Use for finding documentation, articles, recent information, or any web content. Results are fresh and not cached by Synthetic.",
81
+ parameters: SearchParams,
82
+
83
+ async execute(
84
+ _toolCallId: string,
85
+ params: SearchParamsType,
86
+ signal: AbortSignal | undefined,
87
+ onUpdate: (result: AgentToolResult<WebSearchDetails>) => void,
88
+ _ctx: ExtensionContext,
89
+ ): Promise<AgentToolResult<WebSearchDetails>> {
90
+ // Check for API key
91
+ const apiKey = process.env.SYNTHETIC_API_KEY;
92
+ if (!apiKey) {
93
+ const error = "SYNTHETIC_API_KEY environment variable is required";
94
+ return {
95
+ content: [{ type: "text", text: `Error: ${error}` }],
96
+ details: { error, isError: true },
97
+ };
98
+ }
99
+
100
+ // Send progress update
101
+ onUpdate({
102
+ content: [{ type: "text", text: "Searching..." }],
103
+ details: { query: params.query },
104
+ });
105
+
106
+ try {
107
+ // Make API request
108
+ const response = await fetch("https://api.synthetic.new/v2/search", {
109
+ method: "POST",
110
+ headers: {
111
+ Authorization: `Bearer ${apiKey}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify({ query: params.query }),
115
+ signal,
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const errorText = await response.text();
120
+ const error = `Search API error: ${response.status} ${errorText}`;
121
+ return {
122
+ content: [{ type: "text", text: `Error: ${error}` }],
123
+ details: { error, isError: true },
124
+ };
125
+ }
126
+
127
+ // Parse response
128
+ let data: SyntheticSearchResponse;
129
+ try {
130
+ data = await response.json();
131
+ } catch (parseError) {
132
+ const error =
133
+ parseError instanceof Error
134
+ ? `Failed to parse search results: ${parseError.message}`
135
+ : "Failed to parse search results";
136
+ return {
137
+ content: [{ type: "text", text: `Error: ${error}` }],
138
+ details: { error, isError: true },
139
+ };
140
+ }
141
+
142
+ // Format results for LLM
143
+ let content = `Found ${data.results.length} result(s):\n\n`;
144
+ for (const result of data.results) {
145
+ content += `## ${result.title}\n`;
146
+ content += `URL: ${result.url}\n`;
147
+ content += `Published: ${result.published}\n`;
148
+ content += `\n${result.text}\n`;
149
+ content += "\n---\n\n";
150
+ }
151
+
152
+ return {
153
+ content: [{ type: "text", text: content }],
154
+ details: {
155
+ results: data.results,
156
+ query: params.query,
157
+ },
158
+ };
159
+ } catch (error) {
160
+ // Handle abort signal
161
+ if (error instanceof Error && error.name === "AbortError") {
162
+ return {
163
+ content: [{ type: "text", text: "Search cancelled" }],
164
+ details: { query: params.query },
165
+ };
166
+ }
167
+
168
+ // Handle other errors
169
+ const message =
170
+ error instanceof Error ? error.message : "Unknown error occurred";
171
+ return {
172
+ content: [{ type: "text", text: `Error: ${message}` }],
173
+ details: { error: message, isError: true },
174
+ };
175
+ }
176
+ },
177
+
178
+ renderCall(args: SearchParamsType, theme: Theme): Text {
179
+ let text = theme.fg("toolTitle", theme.bold("Synthetic: WebSearch "));
180
+ text += theme.fg("accent", `"${args.query}"`);
181
+ return new Text(text, 0, 0);
182
+ },
183
+
184
+ renderResult(
185
+ result: AgentToolResult<WebSearchDetails>,
186
+ options: ToolRenderResultOptions,
187
+ theme: Theme,
188
+ ): Text {
189
+ const { expanded, isPartial } = options;
190
+
191
+ // Handle partial/loading state
192
+ if (isPartial) {
193
+ const text =
194
+ result.content?.[0]?.type === "text"
195
+ ? result.content[0].text
196
+ : "Searching...";
197
+ return new Text(theme.fg("dim", text), 0, 0);
198
+ }
199
+
200
+ const details = result.details;
201
+
202
+ // Handle error state
203
+ if (details?.isError) {
204
+ const errorMsg =
205
+ result.content?.[0]?.type === "text"
206
+ ? result.content[0].text
207
+ : "Error occurred";
208
+ return new Text(theme.fg("error", errorMsg), 0, 0);
209
+ }
210
+
211
+ // Handle success state
212
+ const results = details?.results || [];
213
+ let text = theme.fg("success", `✓ Found ${results.length} result(s)`);
214
+
215
+ // Collapsed view
216
+ if (!expanded && results.length > 0) {
217
+ const first = results[0];
218
+ text += `\n ${theme.fg("dim", `${first.title}`)}`;
219
+ if (results.length > 1) {
220
+ text += theme.fg("dim", ` (${results.length - 1} more)`);
221
+ }
222
+ text += theme.fg("muted", ` [Ctrl+O to expand]`);
223
+ }
224
+
225
+ // Expanded view
226
+ if (expanded) {
227
+ for (const r of results) {
228
+ text += `\n\n${theme.fg("accent", theme.bold(r.title))}`;
229
+ text += `\n${theme.fg("dim", r.url)}`;
230
+ if (r.text) {
231
+ const preview = r.text.slice(0, 200);
232
+ text += `\n${theme.fg("muted", preview)}`;
233
+ if (r.text.length > 200) {
234
+ text += theme.fg("dim", "...");
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ return new Text(text, 0, 0);
241
+ },
242
+ });
243
+ }
@@ -0,0 +1,7 @@
1
+ export interface QuotasResponse {
2
+ subscription: {
3
+ limit: number;
4
+ requests: number;
5
+ renewsAt: string;
6
+ };
7
+ }