@aliou/pi-synthetic 0.4.7 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -3,7 +3,7 @@ import type { Component } from "@mariozechner/pi-tui";
3
3
  import { QuotasDisplayComponent } from "../components/quotas-display";
4
4
  import { QuotasErrorComponent } from "../components/quotas-error";
5
5
  import { QuotasLoadingComponent } from "../components/quotas-loading";
6
- import type { QuotasResponse } from "../types/quotas";
6
+ import { fetchQuotas } from "../utils/quotas";
7
7
 
8
8
  export function registerQuotasCommand(pi: ExtensionAPI): void {
9
9
  pi.registerCommand("synthetic:quotas", {
@@ -20,7 +20,13 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
20
20
  "Failed to fetch quotas",
21
21
  );
22
22
  } else {
23
- currentComponent = new QuotasDisplayComponent(theme, quotas);
23
+ currentComponent = new QuotasDisplayComponent(
24
+ theme,
25
+ quotas,
26
+ () => {
27
+ done(null);
28
+ },
29
+ );
24
30
  }
25
31
  tui.requestRender();
26
32
  })
@@ -35,90 +41,28 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
35
41
  return {
36
42
  render: (width: number) => currentComponent.render(width),
37
43
  invalidate: () => currentComponent.invalidate(),
38
- handleInput: (_data: string) => {
44
+ handleInput: (data: string) => {
45
+ if (currentComponent.handleInput) {
46
+ return currentComponent.handleInput(data);
47
+ }
39
48
  done(null);
49
+ return true;
40
50
  },
41
51
  };
42
52
  });
43
53
 
44
- // RPC fallback: custom() returned undefined
54
+ // RPC fallback: return JSON
45
55
  if (result === undefined) {
46
56
  const quotas = await fetchQuotas();
47
57
  if (!quotas) {
48
- ctx.ui.notify("Failed to fetch quotas", "error");
58
+ ctx.ui.notify(
59
+ JSON.stringify({ error: "Failed to fetch quotas" }),
60
+ "error",
61
+ );
49
62
  return;
50
63
  }
51
- ctx.ui.notify(formatQuotasPlain(quotas), "info");
64
+ ctx.ui.notify(JSON.stringify(quotas, null, 2), "info");
52
65
  }
53
66
  },
54
67
  });
55
68
  }
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
- }
@@ -1,139 +1,240 @@
1
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";
2
+ import type { Component, TUI } from "@mariozechner/pi-tui";
3
+ import { matchesKey } from "@mariozechner/pi-tui";
4
4
  import type { QuotasResponse } from "../types/quotas";
5
+ import { TabbedScrollablePanel } from "./tabbed-panel";
5
6
 
6
7
  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,
8
+ private panel: TabbedScrollablePanel;
9
+ private onClose: () => void;
10
+
11
+ constructor(theme: Theme, quotas: QuotasResponse, onClose: () => void) {
12
+ this.onClose = onClose;
13
+
14
+ this.panel = new TabbedScrollablePanel(
15
+ {
16
+ title: "Synthetic API Quotas",
17
+ tabs: [
18
+ {
19
+ label: "Completions",
20
+ buildContent: () =>
21
+ buildHybridLayout(theme, quotas.subscription, 5), // 5-hour window
22
+ },
23
+ {
24
+ label: "Search",
25
+ buildContent: () =>
26
+ buildHybridLayout(theme, quotas.search.hourly, 1), // 1 hour
27
+ },
28
+ {
29
+ label: "Free tool call",
30
+ buildContent: () =>
31
+ buildToolCallsLayout(theme, quotas.freeToolCalls, 24), // 24 hours (daily)
32
+ },
33
+ ],
34
+ onClose: onClose,
35
+ },
36
+ null as unknown as TUI,
37
+ theme,
22
38
  );
39
+ }
23
40
 
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));
41
+ handleInput(data: string): boolean {
42
+ if (matchesKey(data, "escape") || data === "q") {
43
+ this.onClose();
44
+ return true;
42
45
  }
46
+ return this.panel.handleInput(data);
47
+ }
43
48
 
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
- );
49
+ render(width: number): string[] {
50
+ return this.panel.render(width);
51
+ }
57
52
 
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
- );
53
+ invalidate(): void {
54
+ this.panel.invalidate();
55
+ }
56
+ }
96
57
 
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);
58
+ // Layout: bar + pct/cur-total + pace/reset
59
+ function buildHybridLayout(
60
+ theme: Theme,
61
+ quota: { limit: number; requests: number; renewsAt: string },
62
+ periodHours: number,
63
+ ): string[] {
64
+ const percentUsed = Math.round((quota.requests / quota.limit) * 100);
65
+ const renewsAt = new Date(quota.renewsAt);
66
+ const now = new Date();
67
+
68
+ const lines: string[] = [];
69
+ const barWidth = 50;
70
+
71
+ const usedStr = quota.requests.toLocaleString();
72
+ const limitStr = quota.limit.toLocaleString();
73
+
74
+ // Color based on usage
75
+ const usedColor =
76
+ percentUsed >= 100 ? "error" : percentUsed > 75 ? "warning" : "success";
77
+
78
+ // Calculate pace with known period
79
+ const totalPeriod = periodHours * 60 * 60 * 1000;
80
+ const timeUntilReset = Math.max(0, renewsAt.getTime() - now.getTime());
81
+ const timeElapsed = totalPeriod - timeUntilReset;
82
+ const percentTimeElapsed = (timeElapsed / totalPeriod) * 100;
83
+ const paceDiff = percentUsed - percentTimeElapsed;
84
+
85
+ let paceStr: string;
86
+ let paceColor: "success" | "dim" | "error";
87
+ if (paceDiff < -10) {
88
+ paceStr = `${Math.round(Math.abs(paceDiff))}% behind pace`;
89
+ paceColor = "success";
90
+ } else if (paceDiff > 10) {
91
+ paceStr = `${Math.round(paceDiff)}% ahead of pace`;
92
+ paceColor = "error";
93
+ } else {
94
+ paceStr = "within pace";
95
+ paceColor = "dim";
102
96
  }
103
97
 
104
- render(width: number): string[] {
105
- return this.container.render(width);
98
+ // Row above bar: pct left, cur/total right
99
+ const pctStr = `${percentUsed}% used`;
100
+ const totalDisplay = `${usedStr}/${limitStr}`;
101
+ const spacing = " ".repeat(
102
+ Math.max(1, barWidth - pctStr.length - totalDisplay.length),
103
+ );
104
+ lines.push(
105
+ ` ${theme.fg(usedColor, pctStr)}${spacing}${theme.fg("dim", totalDisplay)}`,
106
+ );
107
+
108
+ // Bar
109
+ const usedWidth = Math.round((percentUsed / 100) * barWidth);
110
+ let bar: string;
111
+ if (usedWidth >= barWidth) {
112
+ bar = theme.fg("error", "█".repeat(barWidth));
113
+ } else if (percentUsed > 75) {
114
+ bar =
115
+ theme.fg("warning", "█".repeat(usedWidth)) +
116
+ theme.fg("dim", "█".repeat(barWidth - usedWidth));
117
+ } else {
118
+ bar =
119
+ theme.fg("success", "█".repeat(usedWidth)) +
120
+ theme.fg("dim", "█".repeat(barWidth - usedWidth));
106
121
  }
122
+ lines.push(` ${bar}`);
123
+
124
+ // Row below bar: pace left, reset right
125
+ const resetStr = formatShortTime(renewsAt);
126
+ const paceSpacing = " ".repeat(
127
+ Math.max(1, barWidth - paceStr.length - resetStr.length),
128
+ );
129
+ lines.push(
130
+ ` ${theme.fg(paceColor, paceStr)}${paceSpacing}${theme.fg("dim", resetStr)}`,
131
+ );
132
+
133
+ return lines;
134
+ }
107
135
 
108
- invalidate(): void {
109
- this.container.invalidate();
136
+ // Layout for free tool calls - shows remaining, not usage
137
+ function buildToolCallsLayout(
138
+ theme: Theme,
139
+ quota: { limit: number; requests: number; renewsAt: string },
140
+ periodHours: number,
141
+ ): string[] {
142
+ const remaining = quota.limit - quota.requests;
143
+ const percentRemaining = Math.round((remaining / quota.limit) * 100);
144
+ const renewsAt = new Date(quota.renewsAt);
145
+ const now = new Date();
146
+
147
+ const lines: string[] = [];
148
+ const barWidth = 50;
149
+
150
+ const remainingStr = remaining.toLocaleString();
151
+
152
+ // Color based on remaining (inverse of usage)
153
+ const remainingColor =
154
+ remaining <= 0 ? "error" : percentRemaining < 25 ? "warning" : "success";
155
+
156
+ // Calculate pace (how fast you're consuming free calls)
157
+ const totalPeriod = periodHours * 60 * 60 * 1000;
158
+ const timeUntilReset = Math.max(0, renewsAt.getTime() - now.getTime());
159
+ const timeElapsed = totalPeriod - timeUntilReset;
160
+ const percentTimeElapsed = (timeElapsed / totalPeriod) * 100;
161
+ const expectedRemaining = Math.round(
162
+ quota.limit * (1 - percentTimeElapsed / 100),
163
+ );
164
+ const remainingDiff = remaining - expectedRemaining;
165
+
166
+ let paceStr: string;
167
+ let paceColor: "success" | "dim" | "error";
168
+ if (remainingDiff > quota.limit * 0.1) {
169
+ paceStr = `${remainingDiff.toLocaleString()} more than expected`;
170
+ paceColor = "success";
171
+ } else if (remainingDiff < -quota.limit * 0.1) {
172
+ paceStr = `${Math.abs(remainingDiff).toLocaleString()} fewer than expected`;
173
+ paceColor = "error";
174
+ } else {
175
+ paceStr = "on track";
176
+ paceColor = "dim";
177
+ }
178
+
179
+ // Row above bar: pct remaining left, ratio right (like other tabs)
180
+ const pctStr = `${percentRemaining}% remaining`;
181
+ const ratioStr = `${remainingStr}/${quota.limit.toLocaleString()}`;
182
+ const spacing = " ".repeat(
183
+ Math.max(1, barWidth - pctStr.length - ratioStr.length),
184
+ );
185
+ lines.push(
186
+ ` ${theme.fg(remainingColor, pctStr)}${spacing}${theme.fg("dim", ratioStr)}`,
187
+ );
188
+
189
+ // Bar (shows remaining, not used - so full bar = all remaining)
190
+ const remainingWidth = Math.round((percentRemaining / 100) * barWidth);
191
+ let bar: string;
192
+ if (remaining <= 0) {
193
+ bar = theme.fg("dim", "█".repeat(barWidth));
194
+ } else if (percentRemaining < 25) {
195
+ bar =
196
+ theme.fg("warning", "█".repeat(remainingWidth)) +
197
+ theme.fg("dim", "█".repeat(barWidth - remainingWidth));
198
+ } else {
199
+ bar =
200
+ theme.fg("success", "█".repeat(remainingWidth)) +
201
+ theme.fg("dim", "█".repeat(barWidth - remainingWidth));
110
202
  }
203
+ lines.push(` ${bar}`);
204
+
205
+ // Row below bar: pace left, reset right
206
+ const resetStr = formatShortTime(renewsAt);
207
+ const paceSpacing = " ".repeat(
208
+ Math.max(1, barWidth - paceStr.length - resetStr.length),
209
+ );
210
+ lines.push(
211
+ ` ${theme.fg(paceColor, paceStr)}${paceSpacing}${theme.fg("dim", resetStr)}`,
212
+ );
213
+
214
+ // If depleted, show note about paid calls
215
+ if (remaining <= 0) {
216
+ lines.push(` ${theme.fg("dim", "Additional calls will be charged")}`);
217
+ }
218
+
219
+ return lines;
111
220
  }
112
221
 
113
- function formatRelativeTime(date: Date): string {
222
+ function formatShortTime(date: Date): string {
114
223
  const now = new Date();
115
224
  const diffMs = date.getTime() - now.getTime();
116
225
 
117
226
  if (diffMs <= 0) {
118
- return "renews soon";
227
+ return "soon";
119
228
  }
120
229
 
121
- const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
122
- const diffMinutes = Math.ceil(diffMs / (1000 * 60));
123
230
  const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
124
231
  const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
125
232
 
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");
233
+ if (diffHours < 24) {
234
+ return `in ${diffHours}h`;
235
+ } else if (diffDays < 7) {
236
+ return `in ${diffDays}d`;
135
237
  } else {
136
- const years = Math.floor(diffDays / 365);
137
- return rtf.format(years, "year");
238
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
138
239
  }
139
240
  }
@@ -0,0 +1,161 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import type { Component, TUI } from "@mariozechner/pi-tui";
4
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
5
+
6
+ export interface PanelTab {
7
+ label: string;
8
+ buildContent: () => string[];
9
+ }
10
+
11
+ export interface TabbedScrollablePanelOptions {
12
+ title: string;
13
+ tabs: PanelTab[];
14
+ onClose: () => void;
15
+ maxVisible?: number;
16
+ keymap?: "vim" | "default";
17
+ }
18
+
19
+ export class TabbedScrollablePanel implements Component {
20
+ private activeTab = 0;
21
+ private scrollOffset = 0;
22
+ private cachedLines: string[] | null = null;
23
+ private cachedWidth = 0;
24
+ private border: DynamicBorder;
25
+ private options: TabbedScrollablePanelOptions;
26
+ private theme: Theme;
27
+
28
+ constructor(options: TabbedScrollablePanelOptions, _tui: TUI, theme: Theme) {
29
+ this.options = options;
30
+ this.theme = theme;
31
+ this.border = new DynamicBorder((segment) => theme.fg("border", segment));
32
+ }
33
+
34
+ handleInput(data: string): boolean {
35
+ if (matchesKey(data, "escape") || data === "q") {
36
+ this.options.onClose();
37
+ return true;
38
+ }
39
+
40
+ if (matchesKey(data, "tab")) {
41
+ this.activeTab = (this.activeTab + 1) % this.options.tabs.length;
42
+ this.scrollOffset = 0;
43
+ this.invalidate();
44
+ return true;
45
+ }
46
+
47
+ if (matchesKey(data, "shift+tab")) {
48
+ this.activeTab =
49
+ (this.activeTab - 1 + this.options.tabs.length) %
50
+ this.options.tabs.length;
51
+ this.scrollOffset = 0;
52
+ this.invalidate();
53
+ return true;
54
+ }
55
+
56
+ const maxVisible = this.options.maxVisible ?? 16;
57
+ const totalLines = this.cachedLines?.length ?? 0;
58
+ const maxScroll = Math.max(0, totalLines - maxVisible);
59
+
60
+ if (data === "j" || matchesKey(data, "down")) {
61
+ if (this.scrollOffset < maxScroll) {
62
+ this.scrollOffset++;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ if (data === "k" || matchesKey(data, "up")) {
68
+ if (this.scrollOffset > 0) {
69
+ this.scrollOffset--;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ if (data === " " || matchesKey(data, "pageDown")) {
75
+ this.scrollOffset = Math.min(this.scrollOffset + maxVisible, maxScroll);
76
+ return true;
77
+ }
78
+
79
+ if (matchesKey(data, "pageUp")) {
80
+ this.scrollOffset = Math.max(0, this.scrollOffset - maxVisible);
81
+ return true;
82
+ }
83
+
84
+ if (matchesKey(data, "home")) {
85
+ this.scrollOffset = 0;
86
+ return true;
87
+ }
88
+
89
+ if (matchesKey(data, "end")) {
90
+ this.scrollOffset = maxScroll;
91
+ return true;
92
+ }
93
+
94
+ return false;
95
+ }
96
+
97
+ invalidate(): void {
98
+ this.cachedLines = null;
99
+ this.cachedWidth = 0;
100
+ }
101
+
102
+ render(width: number): string[] {
103
+ const tab = this.options.tabs[this.activeTab];
104
+
105
+ if (!this.cachedLines || this.cachedWidth !== width) {
106
+ this.cachedLines = tab ? tab.buildContent() : [];
107
+ this.cachedWidth = width;
108
+ }
109
+
110
+ const lines: string[] = [];
111
+
112
+ lines.push(...this.border.render(width));
113
+ lines.push(
114
+ truncateToWidth(
115
+ ` ${this.theme.fg("accent", this.theme.bold(this.options.title))}`,
116
+ width,
117
+ ),
118
+ );
119
+ lines.push(this.renderTabBar(width));
120
+ lines.push("");
121
+
122
+ // Content - no forced padding, just render what we have
123
+ for (const line of this.cachedLines) {
124
+ lines.push(truncateToWidth(` ${line}`, width));
125
+ }
126
+
127
+ // Footer directly after content
128
+ lines.push("");
129
+ lines.push(
130
+ truncateToWidth(
131
+ this.theme.fg("dim", " Tab/S-Tab switch tabs q/Esc close"),
132
+ width,
133
+ ),
134
+ );
135
+ lines.push(...this.border.render(width));
136
+
137
+ return lines;
138
+ }
139
+
140
+ private renderTabBar(width: number): string {
141
+ const parts: string[] = [];
142
+
143
+ for (let i = 0; i < this.options.tabs.length; i++) {
144
+ const tab = this.options.tabs[i];
145
+ if (!tab) continue;
146
+ const active = i === this.activeTab;
147
+
148
+ if (active) {
149
+ parts.push(this.theme.fg("accent", this.theme.bold(` ${tab.label} `)));
150
+ } else {
151
+ parts.push(this.theme.fg("dim", ` ${tab.label} `));
152
+ }
153
+
154
+ if (i < this.options.tabs.length - 1) {
155
+ parts.push(this.theme.fg("borderMuted", "│"));
156
+ }
157
+ }
158
+
159
+ return truncateToWidth(` ${parts.join("")}`, width);
160
+ }
161
+ }
@@ -0,0 +1,145 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { QuotasResponse } from "../types/quotas";
3
+ import { fetchQuotas, formatResetTime } from "../utils/quotas";
4
+
5
+ interface RateWindow {
6
+ label: string;
7
+ usedPercent: number;
8
+ resetDescription?: string;
9
+ resetAt?: string;
10
+ }
11
+
12
+ interface UsageSnapshot {
13
+ provider: string;
14
+ displayName: string;
15
+ windows: RateWindow[];
16
+ lastSuccessAt?: number;
17
+ }
18
+
19
+ interface SubCoreSettingsPayload {
20
+ settings?: {
21
+ behavior?: {
22
+ refreshInterval: number;
23
+ };
24
+ };
25
+ }
26
+
27
+ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
28
+ const windows: RateWindow[] = [];
29
+
30
+ if (quotas.subscription) {
31
+ const pct =
32
+ (quotas.subscription.requests / quotas.subscription.limit) * 100;
33
+ windows.push({
34
+ label: "5h",
35
+ usedPercent: Math.round(pct),
36
+ resetDescription: formatResetTime(quotas.subscription.renewsAt),
37
+ resetAt: quotas.subscription.renewsAt,
38
+ });
39
+ }
40
+
41
+ if (quotas.search?.hourly) {
42
+ const pct =
43
+ (quotas.search.hourly.requests / quotas.search.hourly.limit) * 100;
44
+ windows.push({
45
+ label: "Search",
46
+ usedPercent: Math.round(pct),
47
+ resetDescription: formatResetTime(quotas.search.hourly.renewsAt),
48
+ resetAt: quotas.search.hourly.renewsAt,
49
+ });
50
+ }
51
+
52
+ if (quotas.freeToolCalls) {
53
+ const pct =
54
+ (quotas.freeToolCalls.requests / quotas.freeToolCalls.limit) * 100;
55
+ windows.push({
56
+ label: "Free",
57
+ usedPercent: Math.round(pct),
58
+ resetDescription: formatResetTime(quotas.freeToolCalls.renewsAt),
59
+ resetAt: quotas.freeToolCalls.renewsAt,
60
+ });
61
+ }
62
+
63
+ return {
64
+ provider: "synthetic",
65
+ displayName: "Synthetic",
66
+ windows,
67
+ lastSuccessAt: Date.now(),
68
+ };
69
+ }
70
+
71
+ async function emitCurrentUsage(pi: ExtensionAPI): Promise<void> {
72
+ const quotas = await fetchQuotas();
73
+ if (!quotas) return;
74
+ pi.events.emit("sub-core:update-current", {
75
+ state: { provider: "synthetic", usage: toUsageSnapshot(quotas) },
76
+ });
77
+ }
78
+
79
+ export function registerSubIntegration(pi: ExtensionAPI): void {
80
+ if (!process.env.SYNTHETIC_API_KEY) return;
81
+
82
+ let interval: NodeJS.Timeout | undefined;
83
+ let refreshMs = 60000;
84
+ let subCoreReady = false;
85
+ let currentProvider: string | undefined;
86
+
87
+ function isSynthetic(): boolean {
88
+ return currentProvider === "synthetic";
89
+ }
90
+
91
+ function stop(): void {
92
+ if (interval) {
93
+ clearInterval(interval);
94
+ interval = undefined;
95
+ }
96
+ }
97
+
98
+ function start(): void {
99
+ stop();
100
+ if (!subCoreReady || !isSynthetic()) {
101
+ return;
102
+ }
103
+ emitCurrentUsage(pi);
104
+ const ms = Math.max(10000, refreshMs);
105
+ interval = setInterval(() => {
106
+ if (isSynthetic()) emitCurrentUsage(pi);
107
+ }, ms);
108
+ interval.unref?.();
109
+ }
110
+
111
+ // Custom events (inter-extension bus)
112
+ pi.events.on("sub-core:ready", () => {
113
+ subCoreReady = true;
114
+ start();
115
+ });
116
+
117
+ pi.events.on("sub-core:settings:updated", (data: unknown) => {
118
+ const payload = data as SubCoreSettingsPayload;
119
+ if (payload.settings?.behavior?.refreshInterval) {
120
+ refreshMs = payload.settings.behavior.refreshInterval * 1000;
121
+ if (interval) start();
122
+ }
123
+ });
124
+
125
+ // Lifecycle events (pi.on, not pi.events.on)
126
+ pi.on("session_start", (_event, ctx) => {
127
+ currentProvider = ctx.model?.provider;
128
+ start();
129
+ });
130
+
131
+ pi.on("model_select", (event, _ctx) => {
132
+ currentProvider = event.model?.provider;
133
+ if (isSynthetic()) {
134
+ emitCurrentUsage(pi);
135
+ start();
136
+ } else {
137
+ stop();
138
+ }
139
+ });
140
+
141
+ pi.on("session_shutdown", () => {
142
+ currentProvider = undefined;
143
+ stop();
144
+ });
145
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { registerQuotasCommand } from "./commands/quotas";
3
3
  import { registerSyntheticWebSearchHooks } from "./hooks/search-tool-availability";
4
+ import { registerSubIntegration } from "./hooks/sub-integration";
4
5
  import { registerSyntheticProvider } from "./providers/index";
5
6
  import { registerSyntheticWebSearchTool } from "./tools/search";
6
7
 
@@ -11,5 +12,6 @@ export default async function (pi: ExtensionAPI) {
11
12
 
12
13
  if (process.env.SYNTHETIC_API_KEY) {
13
14
  registerQuotasCommand(pi);
15
+ registerSubIntegration(pi);
14
16
  }
15
17
  }
@@ -235,4 +235,19 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
235
235
  contextWindow: 262144,
236
236
  maxTokens: 32000,
237
237
  },
238
+ // API: hf:Qwen/Qwen3.5-397B-A17B → ctx=262144, out=32000
239
+ {
240
+ id: "hf:Qwen/Qwen3.5-397B-A17B",
241
+ name: "Qwen/Qwen3.5-397B-A17B",
242
+ reasoning: false,
243
+ input: ["text"],
244
+ cost: {
245
+ input: 0.6,
246
+ output: 3,
247
+ cacheRead: 0.6,
248
+ cacheWrite: 0,
249
+ },
250
+ contextWindow: 262144,
251
+ maxTokens: 32000,
252
+ },
238
253
  ];
@@ -4,4 +4,16 @@ export interface QuotasResponse {
4
4
  requests: number;
5
5
  renewsAt: string;
6
6
  };
7
+ search: {
8
+ hourly: {
9
+ limit: number;
10
+ requests: number;
11
+ renewsAt: string;
12
+ };
13
+ };
14
+ freeToolCalls: {
15
+ limit: number;
16
+ requests: number;
17
+ renewsAt: string;
18
+ };
7
19
  }
@@ -0,0 +1,33 @@
1
+ import type { QuotasResponse } from "../types/quotas";
2
+
3
+ const API_KEY = process.env.SYNTHETIC_API_KEY;
4
+
5
+ export async function fetchQuotas(): Promise<QuotasResponse | null> {
6
+ if (!API_KEY) return null;
7
+
8
+ try {
9
+ const response = await fetch("https://api.synthetic.new/v2/quotas", {
10
+ headers: { Authorization: `Bearer ${API_KEY}` },
11
+ });
12
+
13
+ if (!response.ok) return null;
14
+ return (await response.json()) as QuotasResponse;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function formatResetTime(renewsAt: string): string {
21
+ const date = new Date(renewsAt);
22
+ const now = new Date();
23
+ const diffMs = date.getTime() - now.getTime();
24
+
25
+ if (diffMs <= 0) return "soon";
26
+
27
+ const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
28
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
29
+
30
+ if (diffHours < 24) return `in ${diffHours}h`;
31
+ if (diffDays < 7) return `in ${diffDays}d`;
32
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
33
+ }