@thelioo/opencode-balancer 0.1.8 → 0.2.1

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.
Files changed (176) hide show
  1. package/INSTALL.txt +53 -25
  2. package/README.md +95 -51
  3. package/dist/core/accounts.ts +404 -0
  4. package/dist/core/database.ts +67 -0
  5. package/dist/core/events.ts +75 -0
  6. package/dist/core/native-auth-suppression.ts +36 -0
  7. package/dist/core/native-connect.ts +31 -0
  8. package/dist/core/path.ts +34 -0
  9. package/dist/core/pending.ts +351 -0
  10. package/dist/core/priority.ts +193 -0
  11. package/dist/core/schema.ts +439 -0
  12. package/dist/core/time.ts +3 -0
  13. package/dist/core/types.ts +72 -0
  14. package/dist/core/usage/index.ts +23 -0
  15. package/dist/core/usage/providers/copilot.ts +243 -0
  16. package/dist/core/usage/providers/openai.ts +179 -0
  17. package/dist/core/usage/redact.ts +80 -0
  18. package/dist/core/usage/store.ts +66 -0
  19. package/dist/core/usage/types.ts +24 -0
  20. package/dist/index.js +173 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/server/auth-watcher.ts +318 -0
  23. package/dist/server/commands.ts +58 -0
  24. package/dist/server/fetch-patch.ts +162 -0
  25. package/dist/server/index.ts +134 -0
  26. package/dist/server/native.ts +49 -0
  27. package/dist/server/request-balancer.ts +67 -0
  28. package/dist/tui/actions.ts +176 -112
  29. package/dist/tui/balancer-bar-sync.ts +55 -45
  30. package/dist/tui/components/alias-dialog.tsx +71 -56
  31. package/dist/tui/components/dashboard.tsx +530 -358
  32. package/dist/tui/components/priority-screen.tsx +389 -267
  33. package/dist/tui/components/provider-model-dialog.tsx +71 -64
  34. package/dist/tui/components/rename-dialog.tsx +35 -28
  35. package/dist/tui/components/sidebar.tsx +103 -79
  36. package/dist/tui/components/status-indicator.tsx +78 -59
  37. package/dist/tui/components/usage-bar.tsx +18 -7
  38. package/dist/tui/components/usage-display.tsx +32 -16
  39. package/dist/tui/connect.ts +104 -73
  40. package/dist/tui/dashboard-keys.ts +53 -41
  41. package/dist/tui/native-model-apply.ts +45 -36
  42. package/dist/tui/priority-keys.ts +44 -36
  43. package/dist/tui/provider-models.ts +32 -25
  44. package/dist/tui/responsive.ts +10 -7
  45. package/dist/tui/selected-account-bar-sync.ts +23 -23
  46. package/dist/tui/selection-colors.ts +38 -30
  47. package/dist/tui/state.ts +61 -44
  48. package/dist/tui/status-format.ts +24 -20
  49. package/dist/tui/tui.js +165 -153
  50. package/dist/tui/tui.js.map +1 -1
  51. package/dist/tui/tui.tsx +194 -144
  52. package/dist/tui/usage-auto-refresh.ts +52 -45
  53. package/dist/tui/usage-format.ts +9 -9
  54. package/package.json +61 -52
  55. package/dist/core/accounts.d.ts +0 -14
  56. package/dist/core/accounts.js +0 -260
  57. package/dist/core/accounts.js.map +0 -1
  58. package/dist/core/database.d.ts +0 -4
  59. package/dist/core/database.js +0 -69
  60. package/dist/core/database.js.map +0 -1
  61. package/dist/core/events.d.ts +0 -18
  62. package/dist/core/events.js +0 -39
  63. package/dist/core/events.js.map +0 -1
  64. package/dist/core/native-auth-suppression.d.ts +0 -3
  65. package/dist/core/native-auth-suppression.js +0 -19
  66. package/dist/core/native-auth-suppression.js.map +0 -1
  67. package/dist/core/native-connect.d.ts +0 -4
  68. package/dist/core/native-connect.js +0 -19
  69. package/dist/core/native-connect.js.map +0 -1
  70. package/dist/core/path.d.ts +0 -4
  71. package/dist/core/path.js +0 -26
  72. package/dist/core/path.js.map +0 -1
  73. package/dist/core/pending.d.ts +0 -9
  74. package/dist/core/pending.js +0 -237
  75. package/dist/core/pending.js.map +0 -1
  76. package/dist/core/priority.d.ts +0 -20
  77. package/dist/core/priority.js +0 -120
  78. package/dist/core/priority.js.map +0 -1
  79. package/dist/core/schema.d.ts +0 -2
  80. package/dist/core/schema.js +0 -265
  81. package/dist/core/schema.js.map +0 -1
  82. package/dist/core/time.d.ts +0 -1
  83. package/dist/core/time.js +0 -4
  84. package/dist/core/time.js.map +0 -1
  85. package/dist/core/types.d.ts +0 -59
  86. package/dist/core/types.js +0 -2
  87. package/dist/core/types.js.map +0 -1
  88. package/dist/core/usage/index.d.ts +0 -4
  89. package/dist/core/usage/index.js +0 -16
  90. package/dist/core/usage/index.js.map +0 -1
  91. package/dist/core/usage/providers/copilot.d.ts +0 -2
  92. package/dist/core/usage/providers/copilot.js +0 -169
  93. package/dist/core/usage/providers/copilot.js.map +0 -1
  94. package/dist/core/usage/providers/openai.d.ts +0 -2
  95. package/dist/core/usage/providers/openai.js +0 -133
  96. package/dist/core/usage/providers/openai.js.map +0 -1
  97. package/dist/core/usage/redact.d.ts +0 -3
  98. package/dist/core/usage/redact.js +0 -67
  99. package/dist/core/usage/redact.js.map +0 -1
  100. package/dist/core/usage/store.d.ts +0 -4
  101. package/dist/core/usage/store.js +0 -31
  102. package/dist/core/usage/store.js.map +0 -1
  103. package/dist/core/usage/types.d.ts +0 -21
  104. package/dist/core/usage/types.js +0 -2
  105. package/dist/core/usage/types.js.map +0 -1
  106. package/dist/index.d.ts +0 -5
  107. package/dist/server/auth-watcher.d.ts +0 -32
  108. package/dist/server/auth-watcher.js +0 -227
  109. package/dist/server/auth-watcher.js.map +0 -1
  110. package/dist/server/commands.d.ts +0 -2
  111. package/dist/server/commands.js +0 -46
  112. package/dist/server/commands.js.map +0 -1
  113. package/dist/server/fetch-patch.d.ts +0 -3
  114. package/dist/server/fetch-patch.js +0 -118
  115. package/dist/server/fetch-patch.js.map +0 -1
  116. package/dist/server/index.d.ts +0 -8
  117. package/dist/server/index.js +0 -94
  118. package/dist/server/index.js.map +0 -1
  119. package/dist/server/native.d.ts +0 -6
  120. package/dist/server/native.js +0 -35
  121. package/dist/server/native.js.map +0 -1
  122. package/dist/server/request-balancer.d.ts +0 -16
  123. package/dist/server/request-balancer.js +0 -43
  124. package/dist/server/request-balancer.js.map +0 -1
  125. package/dist/tui/actions.d.ts +0 -41
  126. package/dist/tui/actions.js +0 -92
  127. package/dist/tui/actions.js.map +0 -1
  128. package/dist/tui/balancer-bar-sync.d.ts +0 -19
  129. package/dist/tui/balancer-bar-sync.js +0 -45
  130. package/dist/tui/balancer-bar-sync.js.map +0 -1
  131. package/dist/tui/components/alias-dialog.d.ts +0 -4
  132. package/dist/tui/components/dashboard.d.ts +0 -12
  133. package/dist/tui/components/priority-screen.d.ts +0 -9
  134. package/dist/tui/components/provider-model-dialog.d.ts +0 -14
  135. package/dist/tui/components/rename-dialog.d.ts +0 -4
  136. package/dist/tui/components/sidebar.d.ts +0 -10
  137. package/dist/tui/components/status-indicator.d.ts +0 -9
  138. package/dist/tui/components/usage-bar.d.ts +0 -8
  139. package/dist/tui/components/usage-display.d.ts +0 -10
  140. package/dist/tui/connect.d.ts +0 -30
  141. package/dist/tui/connect.js +0 -75
  142. package/dist/tui/connect.js.map +0 -1
  143. package/dist/tui/dashboard-keys.d.ts +0 -45
  144. package/dist/tui/dashboard-keys.js +0 -44
  145. package/dist/tui/dashboard-keys.js.map +0 -1
  146. package/dist/tui/native-model-apply.d.ts +0 -21
  147. package/dist/tui/native-model-apply.js +0 -53
  148. package/dist/tui/native-model-apply.js.map +0 -1
  149. package/dist/tui/priority-keys.d.ts +0 -40
  150. package/dist/tui/priority-keys.js +0 -38
  151. package/dist/tui/priority-keys.js.map +0 -1
  152. package/dist/tui/provider-models.d.ts +0 -19
  153. package/dist/tui/provider-models.js +0 -17
  154. package/dist/tui/provider-models.js.map +0 -1
  155. package/dist/tui/responsive.d.ts +0 -9
  156. package/dist/tui/responsive.js +0 -13
  157. package/dist/tui/responsive.js.map +0 -1
  158. package/dist/tui/selected-account-bar-sync.d.ts +0 -10
  159. package/dist/tui/selected-account-bar-sync.js +0 -26
  160. package/dist/tui/selected-account-bar-sync.js.map +0 -1
  161. package/dist/tui/selection-colors.d.ts +0 -10
  162. package/dist/tui/selection-colors.js +0 -38
  163. package/dist/tui/selection-colors.js.map +0 -1
  164. package/dist/tui/state.d.ts +0 -14
  165. package/dist/tui/state.js +0 -46
  166. package/dist/tui/state.js.map +0 -1
  167. package/dist/tui/status-format.d.ts +0 -15
  168. package/dist/tui/status-format.js +0 -17
  169. package/dist/tui/status-format.js.map +0 -1
  170. package/dist/tui/tui.d.ts +0 -7
  171. package/dist/tui/usage-auto-refresh.d.ts +0 -16
  172. package/dist/tui/usage-auto-refresh.js +0 -46
  173. package/dist/tui/usage-auto-refresh.js.map +0 -1
  174. package/dist/tui/usage-format.d.ts +0 -2
  175. package/dist/tui/usage-format.js +0 -17
  176. package/dist/tui/usage-format.js.map +0 -1
@@ -1,12 +1,24 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
 
3
3
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
4
- import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
4
+ import {
5
+ createMemo,
6
+ createSignal,
7
+ For,
8
+ onCleanup,
9
+ onMount,
10
+ Show,
11
+ } from "solid-js";
5
12
  import { listAccounts } from "../../core/accounts";
6
13
  import { getBalancingEnabled, setBalancingEnabled } from "../../core/priority";
7
14
  import { getUsageSnapshot } from "../../core/usage/store";
8
15
  import type { ProviderUsageSnapshot } from "../../core/usage/types";
9
- import { dashboardSelectionMarker, moveDashboardFocus, reduceDashboardKey, type DashboardFocusArea } from "../dashboard-keys";
16
+ import {
17
+ type DashboardFocusArea,
18
+ dashboardSelectionMarker,
19
+ moveDashboardFocus,
20
+ reduceDashboardKey,
21
+ } from "../dashboard-keys";
10
22
  import { dashboardContentHeight, dashboardLayoutMode } from "../responsive";
11
23
  import { selectedRowColors } from "../selection-colors";
12
24
  import type { BalancerTuiState } from "../state";
@@ -15,379 +27,539 @@ import { UsageSnapshotBar } from "./usage-display";
15
27
  type KeyLike = { name?: string };
16
28
 
17
29
  type DashboardRow =
18
- | { type: "balancing"; key: string }
19
- | { type: "connect"; key: string }
20
- | { type: "account"; key: string; providerID: string; alias: string };
30
+ | { type: "balancing"; key: string }
31
+ | { type: "connect"; key: string }
32
+ | { type: "account"; key: string; providerID: string; alias: string };
21
33
 
22
34
  type HeaderAction = { type: "close" | "priority"; key: string; label: string };
23
35
 
24
36
  export function Dashboard(props: {
25
- api: TuiPluginApi;
26
- state: BalancerTuiState;
27
- onBack: () => void;
28
- openPriority: () => void;
29
- openConnect: () => void;
30
- renameAccount: (providerID: string, alias: string) => void;
31
- removeAccount: (providerID: string, alias: string) => void;
37
+ api: TuiPluginApi;
38
+ state: BalancerTuiState;
39
+ onBack: () => void;
40
+ openPriority: () => void;
41
+ openConnect: () => void;
42
+ renameAccount: (providerID: string, alias: string) => void;
43
+ removeAccount: (providerID: string, alias: string) => void;
32
44
  }) {
33
- const theme = () => props.api.theme.current;
34
- const selectedColors = () => selectedRowColors(theme());
35
- const [confirmAccount, setConfirmAccount] = createSignal<string | undefined>();
36
- const [accounts, setAccounts] = createSignal(listAccounts(props.state.db));
37
- const [balancing, setBalancing] = createSignal(getBalancingEnabled(props.state.db));
38
- const [usage, setUsage] = createSignal<Record<string, ProviderUsageSnapshot | undefined>>({});
39
- const [cursor, setCursor] = createSignal(0);
40
- const [headerCursor, setHeaderCursor] = createSignal(0);
41
- const [focusArea, setFocusArea] = createSignal<DashboardFocusArea>("content");
42
- const layoutMode = () =>
43
- dashboardLayoutMode({
44
- width: (props.api.renderer as unknown as { width?: number }).width,
45
- height: (props.api.renderer as unknown as { height?: number }).height,
46
- });
47
- const compact = () => layoutMode() === "compact";
48
- const contentHeight = () =>
49
- dashboardContentHeight({ height: (props.api.renderer as unknown as { height?: number }).height });
50
- const headerActions: HeaderAction[] = [
51
- { type: "close", key: "close", label: "close" },
52
- { type: "priority", key: "priority", label: "priority" },
53
- ];
54
- const rows = createMemo<DashboardRow[]>(() => [
55
- { type: "balancing", key: "balancing" },
56
- { type: "connect", key: "connect" },
57
- ...accounts().map((account) => ({
58
- type: "account" as const,
59
- key: `account:${account.providerID}/${account.alias}`,
60
- providerID: account.providerID,
61
- alias: account.alias,
62
- })),
63
- ]);
64
- const clampCursor = (value: number) => Math.max(0, Math.min(value, Math.max(0, rows().length - 1)));
65
- const clampHeaderCursor = (value: number) => Math.max(0, Math.min(value, headerActions.length - 1));
66
- const refreshDashboard = () => {
67
- const nextAccounts = listAccounts(props.state.db);
68
- setAccounts(nextAccounts);
69
- setBalancing(getBalancingEnabled(props.state.db));
70
- setUsage(
71
- Object.fromEntries(
72
- nextAccounts.map((account) => [
73
- `${account.providerID}/${account.alias}`,
74
- getUsageSnapshot(props.state.db, account.providerID, account.alias),
75
- ]),
76
- ),
77
- );
78
- setCursor((value) => clampCursor(value));
79
- };
80
- refreshDashboard();
81
- const timer = setInterval(refreshDashboard, 500);
82
- onCleanup(() => clearInterval(timer));
83
- const Button = (buttonProps: { label: string; danger?: boolean; onClick: () => void }) => (
84
- <box onMouseUp={buttonProps.onClick} paddingLeft={0} paddingRight={0}>
85
- <text fg={buttonProps.danger ? theme().warning : theme().accent} wrapMode="none" truncate>
86
- [ {buttonProps.label} ]
87
- </text>
88
- </box>
89
- );
45
+ const theme = () => props.api.theme.current;
46
+ const selectedColors = () => selectedRowColors(theme());
47
+ const [confirmAccount, setConfirmAccount] = createSignal<
48
+ string | undefined
49
+ >();
50
+ const [accounts, setAccounts] = createSignal(listAccounts(props.state.db));
51
+ const [balancing, setBalancing] = createSignal(
52
+ getBalancingEnabled(props.state.db),
53
+ );
54
+ const [usage, setUsage] = createSignal<
55
+ Record<string, ProviderUsageSnapshot | undefined>
56
+ >({});
57
+ const [cursor, setCursor] = createSignal(0);
58
+ const [headerCursor, setHeaderCursor] = createSignal(0);
59
+ const [focusArea, setFocusArea] = createSignal<DashboardFocusArea>("content");
60
+ const layoutMode = () =>
61
+ dashboardLayoutMode({
62
+ height: (props.api.renderer as unknown as { height?: number }).height,
63
+ width: (props.api.renderer as unknown as { width?: number }).width,
64
+ });
65
+ const compact = () => layoutMode() === "compact";
66
+ const contentHeight = () =>
67
+ dashboardContentHeight({
68
+ height: (props.api.renderer as unknown as { height?: number }).height,
69
+ });
70
+ const headerActions: HeaderAction[] = [
71
+ { key: "close", label: "close", type: "close" },
72
+ { key: "priority", label: "priority", type: "priority" },
73
+ ];
74
+ const rows = createMemo<DashboardRow[]>(() => [
75
+ { key: "balancing", type: "balancing" },
76
+ { key: "connect", type: "connect" },
77
+ ...accounts().map((account) => ({
78
+ alias: account.alias,
79
+ key: `account:${account.providerID}/${account.alias}`,
80
+ providerID: account.providerID,
81
+ type: "account" as const,
82
+ })),
83
+ ]);
84
+ const clampCursor = (value: number) =>
85
+ Math.max(0, Math.min(value, Math.max(0, rows().length - 1)));
86
+ const clampHeaderCursor = (value: number) =>
87
+ Math.max(0, Math.min(value, headerActions.length - 1));
88
+ const refreshDashboard = () => {
89
+ const nextAccounts = listAccounts(props.state.db);
90
+ setAccounts(nextAccounts);
91
+ setBalancing(getBalancingEnabled(props.state.db));
92
+ setUsage(
93
+ Object.fromEntries(
94
+ nextAccounts.map((account) => [
95
+ `${account.providerID}/${account.alias}`,
96
+ getUsageSnapshot(props.state.db, account.providerID, account.alias),
97
+ ]),
98
+ ),
99
+ );
100
+ setCursor((value) => clampCursor(value));
101
+ };
102
+ refreshDashboard();
103
+ const timer = setInterval(refreshDashboard, 500);
104
+ onCleanup(() => clearInterval(timer));
105
+ const Button = (buttonProps: {
106
+ label: string;
107
+ danger?: boolean;
108
+ onClick: () => void;
109
+ }) => (
110
+ <box onMouseUp={buttonProps.onClick} paddingLeft={0} paddingRight={0}>
111
+ <text
112
+ fg={buttonProps.danger ? theme().warning : theme().accent}
113
+ truncate
114
+ wrapMode="none"
115
+ >
116
+ [ {buttonProps.label} ]
117
+ </text>
118
+ </box>
119
+ );
90
120
 
91
- const Chip = (chipProps: { keyName: string; label: string; danger?: boolean }) => (
92
- <text fg={chipProps.danger ? theme().warning : theme().accent} wrapMode="none">
93
- [{chipProps.keyName}] <span style={{ fg: theme().textMuted }}>{chipProps.label}</span>
94
- </text>
95
- );
121
+ const Chip = (chipProps: {
122
+ keyName: string;
123
+ label: string;
124
+ danger?: boolean;
125
+ }) => (
126
+ <text
127
+ fg={chipProps.danger ? theme().warning : theme().accent}
128
+ wrapMode="none"
129
+ >
130
+ [{chipProps.keyName}]{" "}
131
+ <span style={{ fg: theme().textMuted }}>{chipProps.label}</span>
132
+ </text>
133
+ );
96
134
 
97
- const SectionTitle = (sectionProps: { label: string; count?: number }) => (
98
- <box flexDirection="row" gap={1} height={1} flexShrink={0}>
99
- <text fg={theme().primary} wrapMode="none">
100
- {sectionProps.label}
101
- </text>
102
- <text fg={theme().textMuted} wrapMode="none">
103
- {sectionProps.count === undefined ? "" : `${sectionProps.count}`}
104
- </text>
105
- </box>
106
- );
135
+ const SectionTitle = (sectionProps: { label: string; count?: number }) => (
136
+ <box flexDirection="row" flexShrink={0} gap={1} height={1}>
137
+ <text fg={theme().primary} wrapMode="none">
138
+ {sectionProps.label}
139
+ </text>
140
+ <text fg={theme().textMuted} wrapMode="none">
141
+ {sectionProps.count === undefined ? "" : `${sectionProps.count}`}
142
+ </text>
143
+ </box>
144
+ );
107
145
 
108
- const Row = (rowProps: { selected?: boolean; onMouseUp?: () => void; children: unknown }) => (
109
- <box
110
- flexDirection="row"
111
- width="100%"
112
- minWidth={0}
113
- height={1}
114
- flexShrink={0}
115
- backgroundColor={rowProps.selected ? selectedColors().bg : undefined}
116
- onMouseUp={rowProps.onMouseUp}
117
- >
118
- {rowProps.children}
119
- </box>
120
- );
146
+ const Row = (rowProps: {
147
+ selected?: boolean;
148
+ onMouseUp?: () => void;
149
+ children: unknown;
150
+ }) => (
151
+ <box
152
+ backgroundColor={rowProps.selected ? selectedColors().bg : undefined}
153
+ flexDirection="row"
154
+ flexShrink={0}
155
+ height={1}
156
+ minWidth={0}
157
+ onMouseUp={rowProps.onMouseUp}
158
+ width="100%"
159
+ >
160
+ {rowProps.children}
161
+ </box>
162
+ );
121
163
 
122
- const selected = (key: string) => focusArea() === "content" && rows()[cursor()]?.key === key;
123
- const headerSelected = (key: string) => focusArea() === "header" && headerActions[headerCursor()]?.key === key;
124
- const rowMarker = (key: string) =>
125
- dashboardSelectionMarker({
126
- focusedArea: focusArea(),
127
- itemArea: "content",
128
- selected: rows()[cursor()]?.key === key,
129
- });
130
- const headerMarker = (key: string) =>
131
- dashboardSelectionMarker({
132
- focusedArea: focusArea(),
133
- itemArea: "header",
134
- selected: headerActions[headerCursor()]?.key === key,
135
- });
136
- const current = () => rows()[cursor()];
137
- const currentHeader = () => headerActions[headerCursor()];
138
- const selectedLabel = () => {
139
- const row = current();
140
- if (focusArea() === "header") return currentHeader()?.type ?? "header";
141
- if (!row) return "none";
142
- if (row.type === "balancing") return "balancing";
143
- if (row.type === "connect") return "connect";
144
- if (row.type === "account") return `account/${row.providerID}/${row.alias}`;
145
- return "priority";
146
- };
164
+ const selected = (key: string) =>
165
+ focusArea() === "content" && rows()[cursor()]?.key === key;
166
+ const headerSelected = (key: string) =>
167
+ focusArea() === "header" && headerActions[headerCursor()]?.key === key;
168
+ const rowMarker = (key: string) =>
169
+ dashboardSelectionMarker({
170
+ focusedArea: focusArea(),
171
+ itemArea: "content",
172
+ selected: rows()[cursor()]?.key === key,
173
+ });
174
+ const headerMarker = (key: string) =>
175
+ dashboardSelectionMarker({
176
+ focusedArea: focusArea(),
177
+ itemArea: "header",
178
+ selected: headerActions[headerCursor()]?.key === key,
179
+ });
180
+ const current = () => rows()[cursor()];
181
+ const currentHeader = () => headerActions[headerCursor()];
182
+ const selectedLabel = () => {
183
+ const row = current();
184
+ if (focusArea() === "header") return currentHeader()?.type ?? "header";
185
+ if (!row) return "none";
186
+ if (row.type === "balancing") return "balancing";
187
+ if (row.type === "connect") return "connect";
188
+ if (row.type === "account") return `account/${row.providerID}/${row.alias}`;
189
+ return "priority";
190
+ };
147
191
 
148
- const selectedAction = () => {
149
- if (focusArea() === "header") {
150
- const action = currentHeader();
151
- return action?.type === "close" ? "Enter closes dashboard" : "Enter opens provider priority matrix";
152
- }
153
- const row = current();
154
- if (!row) return "Enter opens selected item";
155
- if (row.type === "balancing") return "Enter/Space toggles automatic balancing";
156
- if (row.type === "connect") return "Enter/C connects a new provider";
157
- if (confirmAccount() === `${row.providerID}/${row.alias}`) return "Y confirms removal · N cancels";
158
- return "R to rename · D requests removal confirmation";
159
- };
160
- const accountLabel = (account: { providerID: string; alias: string; authType: string }) =>
161
- `${account.providerID}/${account.alias} (${account.authType})`;
162
- const removeCurrent = () => {
163
- const row = current();
164
- if (row?.type === "account") setConfirmAccount(`${row.providerID}/${row.alias}`);
165
- };
192
+ const selectedAction = () => {
193
+ if (focusArea() === "header") {
194
+ const action = currentHeader();
195
+ return action?.type === "close"
196
+ ? "Enter closes dashboard"
197
+ : "Enter opens provider priority matrix";
198
+ }
199
+ const row = current();
200
+ if (!row) return "Enter opens selected item";
201
+ if (row.type === "balancing")
202
+ return "Enter/Space toggles automatic balancing";
203
+ if (row.type === "connect") return "Enter/C connects a new provider";
204
+ if (confirmAccount() === `${row.providerID}/${row.alias}`)
205
+ return "Y confirms removal · N cancels";
206
+ return "R to rename · D requests removal confirmation";
207
+ };
208
+ const accountLabel = (account: {
209
+ providerID: string;
210
+ alias: string;
211
+ authType: string;
212
+ }) => `${account.providerID}/${account.alias} (${account.authType})`;
213
+ const removeCurrent = () => {
214
+ const row = current();
215
+ if (row?.type === "account")
216
+ setConfirmAccount(`${row.providerID}/${row.alias}`);
217
+ };
166
218
 
167
- const toggleBalancing = () => {
168
- setBalancingEnabled(props.state.db, !balancing());
169
- refreshDashboard();
170
- props.state.refresh();
171
- };
219
+ const toggleBalancing = () => {
220
+ setBalancingEnabled(props.state.db, !balancing());
221
+ refreshDashboard();
222
+ props.state.refresh();
223
+ };
172
224
 
173
- const confirmCurrent = () => {
174
- const row = current();
175
- if (row?.type === "account" && confirmAccount() === `${row.providerID}/${row.alias}`) {
176
- setConfirmAccount(undefined);
177
- props.removeAccount(row.providerID, row.alias);
178
- refreshDashboard();
179
- }
180
- };
225
+ const confirmCurrent = () => {
226
+ const row = current();
227
+ if (
228
+ row?.type === "account" &&
229
+ confirmAccount() === `${row.providerID}/${row.alias}`
230
+ ) {
231
+ setConfirmAccount(undefined);
232
+ props.removeAccount(row.providerID, row.alias);
233
+ refreshDashboard();
234
+ }
235
+ };
181
236
 
182
- const runPrimary = () => {
183
- if (focusArea() === "header") {
184
- const action = currentHeader();
185
- if (action?.type === "close") return props.onBack();
186
- return props.openPriority();
187
- }
188
- const row = current();
189
- if (!row) return;
190
- if (row.type === "balancing") return toggleBalancing();
191
- if (row.type === "connect") return props.openConnect();
192
- if (row.type === "account") return setConfirmAccount(`${row.providerID}/${row.alias}`);
193
- };
237
+ const runPrimary = () => {
238
+ if (focusArea() === "header") {
239
+ const action = currentHeader();
240
+ if (action?.type === "close") return props.onBack();
241
+ return props.openPriority();
242
+ }
243
+ const row = current();
244
+ if (!row) return;
245
+ if (row.type === "balancing") return toggleBalancing();
246
+ if (row.type === "connect") return props.openConnect();
247
+ if (row.type === "account")
248
+ return setConfirmAccount(`${row.providerID}/${row.alias}`);
249
+ };
194
250
 
195
- const handleKey = (event: KeyLike) => {
196
- if (props.api.ui.dialog.open) return;
197
- const intent = reduceDashboardKey({ name: event.name ?? "" });
198
- switch (intent.type) {
199
- case "move-cursor":
200
- {
201
- const next = moveDashboardFocus(
202
- { area: focusArea(), cursor: cursor(), rowCount: rows().length },
203
- intent.delta,
204
- );
205
- setFocusArea(next.area);
206
- setCursor(clampCursor(next.cursor));
207
- }
208
- return;
209
- case "move-header":
210
- setFocusArea("header");
211
- setHeaderCursor((value) => clampHeaderCursor(value + intent.delta));
212
- return;
213
- case "primary":
214
- runPrimary();
215
- return;
216
- case "priority":
217
- props.openPriority();
218
- return;
219
- case "connect":
220
- props.openConnect();
221
- return;
222
- case "rename": {
223
- const row = current();
224
- if (row?.type === "account") props.renameAccount(row.providerID, row.alias);
225
- return;
226
- }
227
- case "remove":
228
- removeCurrent();
229
- return;
230
- case "confirm":
231
- confirmCurrent();
232
- return;
233
- case "cancel":
234
- setConfirmAccount(undefined);
235
- return;
236
- case "back":
237
- props.onBack();
238
- return;
239
- default:
240
- return;
241
- }
242
- };
251
+ const handleKey = (event: KeyLike) => {
252
+ if (props.api.ui.dialog.open) return;
253
+ const intent = reduceDashboardKey({ name: event.name ?? "" });
254
+ switch (intent.type) {
255
+ case "move-cursor":
256
+ {
257
+ const next = moveDashboardFocus(
258
+ { area: focusArea(), cursor: cursor(), rowCount: rows().length },
259
+ intent.delta,
260
+ );
261
+ setFocusArea(next.area);
262
+ setCursor(clampCursor(next.cursor));
263
+ }
264
+ return;
265
+ case "move-header":
266
+ setFocusArea("header");
267
+ setHeaderCursor((value) => clampHeaderCursor(value + intent.delta));
268
+ return;
269
+ case "primary":
270
+ runPrimary();
271
+ return;
272
+ case "priority":
273
+ props.openPriority();
274
+ return;
275
+ case "connect":
276
+ props.openConnect();
277
+ return;
278
+ case "rename": {
279
+ const row = current();
280
+ if (row?.type === "account")
281
+ props.renameAccount(row.providerID, row.alias);
282
+ return;
283
+ }
284
+ case "remove":
285
+ removeCurrent();
286
+ return;
287
+ case "confirm":
288
+ confirmCurrent();
289
+ return;
290
+ case "cancel":
291
+ setConfirmAccount(undefined);
292
+ return;
293
+ case "back":
294
+ props.onBack();
295
+ return;
296
+ default:
297
+ return;
298
+ }
299
+ };
243
300
 
244
- let container: { focus?: () => void } | undefined;
245
- onMount(() => container?.focus?.());
301
+ let container: { focus?: () => void } | undefined;
302
+ onMount(() => container?.focus?.());
246
303
 
247
- return (
248
- <box
249
- ref={(ref: unknown) => (container = ref as { focus?: () => void })}
250
- focusable
251
- onKeyDown={(event: KeyLike) => handleKey(event)}
252
- flexDirection="column"
253
- gap={0}
254
- padding={1}
255
- width="100%"
256
- height="100%"
257
- >
258
- <box flexDirection="column" gap={0} paddingBottom={1} flexShrink={0}>
259
- <text fg={theme().primary} wrapMode="none" overflow="hidden" truncate>
260
- opencode-balancer{compact() ? "" : " control center"}
261
- </text>
262
- <Show when={!compact()}>
263
- <text fg={theme().textMuted} wrapMode="none" overflow="hidden" truncate>
264
- Manage accounts, usage, and provider failover.
265
- </text>
266
- </Show>
267
- <box flexDirection="row" gap={1} height={1} flexShrink={0}>
268
- <For each={headerActions}>
269
- {(action, index) => (
270
- <box
271
- height={1}
272
- flexShrink={0}
273
- backgroundColor={headerSelected(action.key) ? selectedColors().bg : undefined}
274
- onMouseUp={() => {
275
- setFocusArea("header");
276
- setHeaderCursor(index());
277
- if (action.type === "close") props.onBack();
278
- else props.openPriority();
279
- }}
280
- >
281
- <text fg={headerSelected(action.key) ? selectedColors().fg : theme().accent} wrapMode="none">
282
- {headerMarker(action.key)} [ {action.label} ]
283
- </text>
284
- </box>
285
- )}
286
- </For>
287
- </box>
288
- </box>
304
+ return (
305
+ <box
306
+ flexDirection="column"
307
+ focusable
308
+ gap={0}
309
+ height="100%"
310
+ onKeyDown={(event: KeyLike) => handleKey(event)}
311
+ padding={1}
312
+ ref={(ref: unknown) => (container = ref as { focus?: () => void })}
313
+ width="100%"
314
+ >
315
+ <box flexDirection="column" flexShrink={0} gap={0} paddingBottom={1}>
316
+ <text fg={theme().primary} overflow="hidden" truncate wrapMode="none">
317
+ opencode-balancer{compact() ? "" : " control center"}
318
+ </text>
319
+ <Show when={!compact()}>
320
+ <text
321
+ fg={theme().textMuted}
322
+ overflow="hidden"
323
+ truncate
324
+ wrapMode="none"
325
+ >
326
+ Manage accounts, usage, and provider failover.
327
+ </text>
328
+ </Show>
329
+ <box flexDirection="row" flexShrink={0} gap={1} height={1}>
330
+ <For each={headerActions}>
331
+ {(action, index) => (
332
+ <box
333
+ backgroundColor={
334
+ headerSelected(action.key) ? selectedColors().bg : undefined
335
+ }
336
+ flexShrink={0}
337
+ height={1}
338
+ onMouseUp={() => {
339
+ setFocusArea("header");
340
+ setHeaderCursor(index());
341
+ if (action.type === "close") props.onBack();
342
+ else props.openPriority();
343
+ }}
344
+ >
345
+ <text
346
+ fg={
347
+ headerSelected(action.key)
348
+ ? selectedColors().fg
349
+ : theme().accent
350
+ }
351
+ wrapMode="none"
352
+ >
353
+ {headerMarker(action.key)} [ {action.label} ]
354
+ </text>
355
+ </box>
356
+ )}
357
+ </For>
358
+ </box>
359
+ </box>
289
360
 
290
- <scrollbox height={contentHeight()} scrollbarOptions={{ visible: false }}>
291
- <box flexDirection="column" gap={0} paddingBottom={1} onMouseUp={() => setCursor(0)}>
292
- <SectionTitle label="BALANCING" />
293
- <Row selected={selected("balancing")} onMouseUp={toggleBalancing}>
294
- <text fg={selected("balancing") ? selectedColors().fg : balancing() ? theme().success : theme().textMuted} wrapMode="none" overflow="hidden" truncate>
295
- {rowMarker("balancing")} Automatic balancing: {balancing() ? "ON" : "OFF"}
296
- </text>
297
- </Row>
298
- <Show when={!compact()}>
299
- <text fg={theme().textMuted} wrapMode="none" overflow="hidden" truncate>
300
- Enter/Space toggles provider failover. Priority config is in the header.
301
- </text>
302
- </Show>
303
- </box>
361
+ <scrollbox height={contentHeight()} scrollbarOptions={{ visible: false }}>
362
+ <box
363
+ flexDirection="column"
364
+ gap={0}
365
+ onMouseUp={() => setCursor(0)}
366
+ paddingBottom={1}
367
+ >
368
+ <SectionTitle label="BALANCING" />
369
+ <Row onMouseUp={toggleBalancing} selected={selected("balancing")}>
370
+ <text
371
+ fg={
372
+ selected("balancing")
373
+ ? selectedColors().fg
374
+ : balancing()
375
+ ? theme().success
376
+ : theme().textMuted
377
+ }
378
+ overflow="hidden"
379
+ truncate
380
+ wrapMode="none"
381
+ >
382
+ {rowMarker("balancing")} Automatic balancing:{" "}
383
+ {balancing() ? "ON" : "OFF"}
384
+ </text>
385
+ </Row>
386
+ <Show when={!compact()}>
387
+ <text
388
+ fg={theme().textMuted}
389
+ overflow="hidden"
390
+ truncate
391
+ wrapMode="none"
392
+ >
393
+ Enter/Space toggles provider failover. Priority config is in the
394
+ header.
395
+ </text>
396
+ </Show>
397
+ </box>
304
398
 
305
- <box flexDirection="column" gap={0} paddingBottom={1}>
306
- <SectionTitle label="ACCOUNTS" count={accounts().length} />
307
- <box flexDirection="column" gap={0} flexShrink={0} onMouseUp={() => setCursor(rows().findIndex((row) => row.key === "connect"))}>
308
- <Row selected={selected("connect")} onMouseUp={props.openConnect}>
309
- <text fg={selected("connect") ? selectedColors().fg : theme().accent} wrapMode="none" overflow="hidden" truncate>
310
- {rowMarker("connect")} New account
311
- </text>
312
- </Row>
313
- <Show when={selected("connect") && !compact()}>
314
- <text fg={theme().textMuted} wrapMode="none" overflow="hidden" truncate>
315
- action: Enter/C opens opencode provider connection
316
- </text>
317
- </Show>
318
- </box>
319
- <Show when={accounts().length > 0} fallback={<text fg={theme().textMuted}>none</text>}>
320
- <For each={accounts()}>
321
- {(account) => (
322
- <box flexDirection="column" gap={0} flexShrink={0} onMouseUp={() => setCursor(rows().findIndex((row) => row.key === `account:${account.providerID}/${account.alias}`))}>
323
- <Row selected={selected(`account:${account.providerID}/${account.alias}`)}>
324
- <text fg={selected(`account:${account.providerID}/${account.alias}`) ? selectedColors().fg : account.disabled ? theme().textMuted : theme().text} wrapMode="none" overflow="hidden" truncate>
325
- {rowMarker(`account:${account.providerID}/${account.alias}`)} {accountLabel(account)}
326
- </text>
327
- </Row>
328
- <Show when={!compact()}>
329
- <UsageSnapshotBar
330
- theme={theme()}
331
- snapshot={usage()[`${account.providerID}/${account.alias}`]}
332
- muted={!selected(`account:${account.providerID}/${account.alias}`)}
333
- />
334
- </Show>
335
- <Show
336
- when={confirmAccount() === `${account.providerID}/${account.alias}`}
337
- fallback={
338
- <Show when={selected(`account:${account.providerID}/${account.alias}`)}>
339
- <text fg={theme().textMuted} wrapMode="none" overflow="hidden" truncate>
340
- action: press D to remove this account
341
- </text>
342
- </Show>
343
- }
344
- >
345
- <box flexDirection="row" gap={1}>
346
- <text fg={theme().warning} wrapMode="none">confirm removal?</text>
347
- <Button
348
- label="yes"
349
- danger
350
- onClick={() => {
351
- setConfirmAccount(undefined);
352
- props.removeAccount(account.providerID, account.alias);
353
- refreshDashboard();
354
- }}
355
- />
356
- <Button label="no" onClick={() => setConfirmAccount(undefined)} />
357
- </box>
358
- </Show>
359
- </box>
360
- )}
361
- </For>
362
- </Show>
363
- </box>
399
+ <box flexDirection="column" gap={0} paddingBottom={1}>
400
+ <SectionTitle count={accounts().length} label="ACCOUNTS" />
401
+ <box
402
+ flexDirection="column"
403
+ flexShrink={0}
404
+ gap={0}
405
+ onMouseUp={() =>
406
+ setCursor(rows().findIndex((row) => row.key === "connect"))
407
+ }
408
+ >
409
+ <Row onMouseUp={props.openConnect} selected={selected("connect")}>
410
+ <text
411
+ fg={selected("connect") ? selectedColors().fg : theme().accent}
412
+ overflow="hidden"
413
+ truncate
414
+ wrapMode="none"
415
+ >
416
+ {rowMarker("connect")} New account
417
+ </text>
418
+ </Row>
419
+ <Show when={selected("connect") && !compact()}>
420
+ <text
421
+ fg={theme().textMuted}
422
+ overflow="hidden"
423
+ truncate
424
+ wrapMode="none"
425
+ >
426
+ action: Enter/C opens opencode provider connection
427
+ </text>
428
+ </Show>
429
+ </box>
430
+ <Show
431
+ fallback={<text fg={theme().textMuted}>none</text>}
432
+ when={accounts().length > 0}
433
+ >
434
+ <For each={accounts()}>
435
+ {(account) => (
436
+ <box
437
+ flexDirection="column"
438
+ flexShrink={0}
439
+ gap={0}
440
+ onMouseUp={() =>
441
+ setCursor(
442
+ rows().findIndex(
443
+ (row) =>
444
+ row.key ===
445
+ `account:${account.providerID}/${account.alias}`,
446
+ ),
447
+ )
448
+ }
449
+ >
450
+ <Row
451
+ selected={selected(
452
+ `account:${account.providerID}/${account.alias}`,
453
+ )}
454
+ >
455
+ <text
456
+ fg={
457
+ selected(
458
+ `account:${account.providerID}/${account.alias}`,
459
+ )
460
+ ? selectedColors().fg
461
+ : account.disabled
462
+ ? theme().textMuted
463
+ : theme().text
464
+ }
465
+ overflow="hidden"
466
+ truncate
467
+ wrapMode="none"
468
+ >
469
+ {rowMarker(
470
+ `account:${account.providerID}/${account.alias}`,
471
+ )}{" "}
472
+ {accountLabel(account)}
473
+ </text>
474
+ </Row>
475
+ <Show when={!compact()}>
476
+ <UsageSnapshotBar
477
+ muted={
478
+ !selected(
479
+ `account:${account.providerID}/${account.alias}`,
480
+ )
481
+ }
482
+ snapshot={
483
+ usage()[`${account.providerID}/${account.alias}`]
484
+ }
485
+ theme={theme()}
486
+ />
487
+ </Show>
488
+ <Show
489
+ fallback={
490
+ <Show
491
+ when={selected(
492
+ `account:${account.providerID}/${account.alias}`,
493
+ )}
494
+ >
495
+ <text
496
+ fg={theme().textMuted}
497
+ overflow="hidden"
498
+ truncate
499
+ wrapMode="none"
500
+ >
501
+ action: press D to remove this account
502
+ </text>
503
+ </Show>
504
+ }
505
+ when={
506
+ confirmAccount() ===
507
+ `${account.providerID}/${account.alias}`
508
+ }
509
+ >
510
+ <box flexDirection="row" gap={1}>
511
+ <text fg={theme().warning} wrapMode="none">
512
+ confirm removal?
513
+ </text>
514
+ <Button
515
+ danger
516
+ label="yes"
517
+ onClick={() => {
518
+ setConfirmAccount(undefined);
519
+ props.removeAccount(
520
+ account.providerID,
521
+ account.alias,
522
+ );
523
+ refreshDashboard();
524
+ }}
525
+ />
526
+ <Button
527
+ label="no"
528
+ onClick={() => setConfirmAccount(undefined)}
529
+ />
530
+ </box>
531
+ </Show>
532
+ </box>
533
+ )}
534
+ </For>
535
+ </Show>
536
+ </box>
537
+ </scrollbox>
364
538
 
365
- </scrollbox>
366
-
367
- <box flexDirection="column" gap={0}>
368
- <Show
369
- when={!compact()}
370
- fallback={
371
- <text fg={theme().textMuted} wrapMode="none" truncate>
372
- ↑↓ move · Enter open · Space toggle · P priority · Esc close
373
- </text>
374
- }
375
- >
376
- <box flexDirection="row" gap={2}>
377
- <Chip keyName="↑↓" label="Move" />
378
- <Chip keyName="Enter" label="Open" />
379
- <Chip keyName="Space" label="Toggle" />
380
- <Chip keyName="C" label="Connect" />
381
- <Chip keyName="P" label="Priority" />
382
- <Chip keyName="R" label="Rename" />
383
- <Chip keyName="D" label="Remove" danger />
384
- <Chip keyName="Esc" label="Close" />
385
- </box>
386
- </Show>
387
- <text fg={theme().textMuted} wrapMode="none" truncate>
388
- selected: {selectedLabel()} · {selectedAction()}
389
- </text>
390
- </box>
391
- </box>
392
- );
539
+ <box flexDirection="column" gap={0}>
540
+ <Show
541
+ fallback={
542
+ <text fg={theme().textMuted} truncate wrapMode="none">
543
+ ↑↓ move · Enter open · Space toggle · P priority · Esc close
544
+ </text>
545
+ }
546
+ when={!compact()}
547
+ >
548
+ <box flexDirection="row" gap={2}>
549
+ <Chip keyName="↑↓" label="Move" />
550
+ <Chip keyName="Enter" label="Open" />
551
+ <Chip keyName="Space" label="Toggle" />
552
+ <Chip keyName="C" label="Connect" />
553
+ <Chip keyName="P" label="Priority" />
554
+ <Chip keyName="R" label="Rename" />
555
+ <Chip danger keyName="D" label="Remove" />
556
+ <Chip keyName="Esc" label="Close" />
557
+ </box>
558
+ </Show>
559
+ <text fg={theme().textMuted} truncate wrapMode="none">
560
+ selected: {selectedLabel()} · {selectedAction()}
561
+ </text>
562
+ </box>
563
+ </box>
564
+ );
393
565
  }