@adeildo/pi-providers 5.2.0 → 5.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.
- package/README.md +15 -10
- package/package.json +2 -2
- package/src/accounts/actions.ts +94 -0
- package/src/accounts/active.ts +5 -6
- package/src/accounts/adopt.ts +70 -0
- package/src/accounts/attach.ts +195 -0
- package/src/accounts/dialogs.ts +332 -0
- package/src/accounts/feature.ts +99 -237
- package/src/accounts/interaction.ts +16 -4
- package/src/accounts/lift.ts +201 -49
- package/src/accounts/lock.ts +54 -0
- package/src/accounts/login.ts +14 -0
- package/src/accounts/pins.ts +22 -37
- package/src/accounts/policy.ts +197 -0
- package/src/accounts/refresh.ts +36 -0
- package/src/accounts/screen.ts +36 -87
- package/src/accounts/settings.ts +38 -0
- package/src/accounts/store.ts +100 -11
- package/src/accounts/types.ts +18 -3
- package/src/errors.ts +168 -0
- package/src/index.ts +2 -1
- package/src/ui/accounts.ts +400 -0
- package/src/ui/frame.ts +27 -0
- package/src/ui/login.ts +278 -0
- package/src/ui/picker.ts +161 -0
- package/src/ui/quota.ts +31 -0
- package/src/usage/anthropic.ts +150 -0
- package/src/usage/quota.ts +76 -0
- package/src/usage/read.ts +87 -0
- package/src/usage/types.ts +47 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// What a failed request is, so an account's limit can be told from a server, plan or token failure.
|
|
2
|
+
// Rules are shared plus one entry per provider; adding Codex means adding an entry.
|
|
3
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
4
|
+
|
|
5
|
+
export const FAILURE_KINDS = [
|
|
6
|
+
"usage",
|
|
7
|
+
"auth",
|
|
8
|
+
"spend",
|
|
9
|
+
"transient",
|
|
10
|
+
"overloaded",
|
|
11
|
+
"server",
|
|
12
|
+
"network",
|
|
13
|
+
"unknown",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type FailureKind = (typeof FAILURE_KINDS)[number];
|
|
17
|
+
|
|
18
|
+
export interface Failure {
|
|
19
|
+
kind: FailureKind;
|
|
20
|
+
status?: number;
|
|
21
|
+
code?: string;
|
|
22
|
+
message: string;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
/** A usage limit belongs to the account, so another account may serve the request. */
|
|
25
|
+
switchable: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Signals {
|
|
29
|
+
status?: number;
|
|
30
|
+
code?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
errorCode?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Rule {
|
|
36
|
+
kind: FailureKind;
|
|
37
|
+
when(signals: Signals): boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SPEND_CODE = "enforced_spend_limit_reached";
|
|
41
|
+
|
|
42
|
+
// The wording of a plan window. A burst throttle says `Rate limited` and keeps the retry.
|
|
43
|
+
const USAGE = /would exceed your account|usage limit|quota|5[ -]?hour|weekly limit|out of credits/i;
|
|
44
|
+
const AUTH = /authentication|invalid[_ -]?(api[_ -]?key|x-api-key|grant|token)|unauthorized|oauth/i;
|
|
45
|
+
const RATE = /rate[ _-]?limit|too many requests|429/i;
|
|
46
|
+
const OVERLOADED = /overloaded|at capacity|high demand/i;
|
|
47
|
+
const SERVER = /internal (server )?error|api_error|bad gateway|service unavailable/i;
|
|
48
|
+
const NETWORK =
|
|
49
|
+
/fetch failed|econnreset|econnrefused|etimedout|getaddrinfo|enotfound|eai_again|socket hang up|network|timed? ?out|terminated/i;
|
|
50
|
+
|
|
51
|
+
/** Rules every provider shares, in the order they are tried. */
|
|
52
|
+
const COMMON: Rule[] = [
|
|
53
|
+
{
|
|
54
|
+
kind: "spend",
|
|
55
|
+
when: (signals) =>
|
|
56
|
+
signals.errorCode === SPEND_CODE ||
|
|
57
|
+
/billing|spend limit|out of budget|insufficient_quota/i.test(haystack(signals)),
|
|
58
|
+
},
|
|
59
|
+
{ kind: "auth", when: (signals) => signals.status === 401 || AUTH.test(haystack(signals)) },
|
|
60
|
+
{ kind: "transient", when: isRateLimit },
|
|
61
|
+
{
|
|
62
|
+
kind: "overloaded",
|
|
63
|
+
when: (signals) => signals.status === 529 || OVERLOADED.test(haystack(signals)),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "server",
|
|
67
|
+
when: (signals) =>
|
|
68
|
+
(signals.status !== undefined && signals.status >= 500) || SERVER.test(haystack(signals)),
|
|
69
|
+
},
|
|
70
|
+
{ kind: "network", when: (signals) => NETWORK.test(haystack(signals)) },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Rules a provider adds before the shared ones, so its own wording wins. */
|
|
74
|
+
const BY_PROVIDER: Record<string, Rule[]> = {
|
|
75
|
+
anthropic: [
|
|
76
|
+
{
|
|
77
|
+
// The subscription windows answer a 429 with this sentence.
|
|
78
|
+
kind: "usage",
|
|
79
|
+
when: (signals) => isRateLimit(signals) && USAGE.test(haystack(signals)),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
interface Body {
|
|
85
|
+
code?: string;
|
|
86
|
+
message?: string;
|
|
87
|
+
requestId?: string;
|
|
88
|
+
errorCode?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function haystack(signals: Signals): string {
|
|
92
|
+
return `${signals.code ?? ""} ${signals.message}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRateLimit(signals: Signals): boolean {
|
|
96
|
+
return (
|
|
97
|
+
signals.status === 429 || signals.code === "rate_limit_error" || RATE.test(haystack(signals))
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function asText(input: unknown): string {
|
|
102
|
+
if (input instanceof Error) return input.message;
|
|
103
|
+
if (typeof input === "string") return input;
|
|
104
|
+
if (input === undefined || input === null) return "";
|
|
105
|
+
try {
|
|
106
|
+
return JSON.stringify(input);
|
|
107
|
+
} catch {
|
|
108
|
+
return String(input);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseBody(text: string): Body {
|
|
113
|
+
const start = text.indexOf("{");
|
|
114
|
+
if (start < 0) return {};
|
|
115
|
+
try {
|
|
116
|
+
const parsed: unknown = JSON.parse(text.slice(start));
|
|
117
|
+
if (!isObject(parsed)) return {};
|
|
118
|
+
const error = isObject(parsed.error) ? parsed.error : undefined;
|
|
119
|
+
const details = error !== undefined && isObject(error.details) ? error.details : undefined;
|
|
120
|
+
return {
|
|
121
|
+
code: stringField(error?.type) ?? stringField(error?.code) ?? stringField(parsed.type),
|
|
122
|
+
message: stringField(error?.message) ?? stringField(parsed.message),
|
|
123
|
+
requestId: stringField(parsed.request_id) ?? stringField(parsed.requestId),
|
|
124
|
+
errorCode: stringField(details?.error_code),
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stringField(value: unknown): string | undefined {
|
|
132
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statusOf(text: string): number | undefined {
|
|
136
|
+
const match = /\b(4\d{2}|5\d{2})\b/.exec(text);
|
|
137
|
+
return match === null ? undefined : Number(match[1]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stripWrappers(text: string): string {
|
|
141
|
+
return text
|
|
142
|
+
.replace(/^Error:\s*/i, "")
|
|
143
|
+
.replace(/^Retry failed after \d+ attempts?:\s*/i, "")
|
|
144
|
+
.replace(/^\d{3}\s*/, "")
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function classifyFailure(input: unknown, provider?: string): Failure {
|
|
149
|
+
const text = asText(input);
|
|
150
|
+
const body = parseBody(text);
|
|
151
|
+
const status = statusOf(text);
|
|
152
|
+
const signals: Signals = {
|
|
153
|
+
...(status === undefined ? {} : { status }),
|
|
154
|
+
...(body.code === undefined ? {} : { code: body.code }),
|
|
155
|
+
...(body.errorCode === undefined ? {} : { errorCode: body.errorCode }),
|
|
156
|
+
message: body.message ?? stripWrappers(text),
|
|
157
|
+
};
|
|
158
|
+
const rules = provider === undefined ? COMMON : [...(BY_PROVIDER[provider] ?? []), ...COMMON];
|
|
159
|
+
const kind = rules.find((rule) => rule.when(signals))?.kind ?? "unknown";
|
|
160
|
+
return {
|
|
161
|
+
kind,
|
|
162
|
+
...(signals.status === undefined ? {} : { status: signals.status }),
|
|
163
|
+
...(signals.code === undefined ? {} : { code: signals.code }),
|
|
164
|
+
message: signals.message,
|
|
165
|
+
...(body.requestId === undefined ? {} : { requestId: body.requestId }),
|
|
166
|
+
switchable: kind === "usage",
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { accounts } from "./accounts/feature.ts";
|
|
|
5
5
|
import { NAME } from "./accounts/names.ts";
|
|
6
6
|
import { subscription } from "./subscription/feature.ts";
|
|
7
7
|
|
|
8
|
-
export { accounts
|
|
8
|
+
export { accounts } from "./accounts/feature.ts";
|
|
9
|
+
export { onAuthFailure, onLimit, WHEN_LIMITED, type WhenLimited } from "./accounts/settings.ts";
|
|
9
10
|
export { accountsPath, AccountStore } from "./accounts/store.ts";
|
|
10
11
|
export { claudeCodeVersion, subscription } from "./subscription/feature.ts";
|
|
11
12
|
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// The accounts of one provider in our frame: which one is in use, what each plan has left, and the
|
|
2
|
+
// keys to switch, rename, remove or add one. Renaming and removing happen in place, so the list stays
|
|
3
|
+
// where it is and says what changed.
|
|
4
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
type Component,
|
|
7
|
+
type Focusable,
|
|
8
|
+
Input,
|
|
9
|
+
Key,
|
|
10
|
+
type KeyId,
|
|
11
|
+
matchesKey,
|
|
12
|
+
type TUI,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
visibleWidth,
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
import type { UsageWindow } from "../usage/types.ts";
|
|
18
|
+
import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
|
|
19
|
+
import { quotaWindows } from "./quota.ts";
|
|
20
|
+
|
|
21
|
+
/** What a row says past the account's name: its plan, or the trouble it is in. */
|
|
22
|
+
export type AccountState =
|
|
23
|
+
| { kind: "quota"; windows: readonly UsageWindow[] }
|
|
24
|
+
| { kind: "reading" }
|
|
25
|
+
| { kind: "signIn" }
|
|
26
|
+
| { kind: "spent"; resetsIn: string }
|
|
27
|
+
| { kind: "quiet" };
|
|
28
|
+
|
|
29
|
+
export interface AccountRow {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
/** `subscription` or `api key`. */
|
|
33
|
+
kind: string;
|
|
34
|
+
inUse: boolean;
|
|
35
|
+
state: AccountState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What closes the list: an account to use or to sign in again, or a new one to add. */
|
|
39
|
+
export type AccountsChoice =
|
|
40
|
+
| { kind: "use"; id: string }
|
|
41
|
+
| { kind: "signIn"; id: string }
|
|
42
|
+
| { kind: "add" };
|
|
43
|
+
|
|
44
|
+
export interface AccountsListing {
|
|
45
|
+
/** The provider as pi names it, like `Anthropic`. */
|
|
46
|
+
provider: string;
|
|
47
|
+
/** Read on every frame, so a plan that arrives late fills its row in. */
|
|
48
|
+
rows(): readonly AccountRow[];
|
|
49
|
+
/** What an empty list means, and what adding one does. */
|
|
50
|
+
empty: string;
|
|
51
|
+
/** Renames in place, answering with a problem or undefined. */
|
|
52
|
+
rename(id: string, label: string): string | undefined;
|
|
53
|
+
/** Removes in place, answering with a problem or undefined. */
|
|
54
|
+
remove(id: string): string | undefined;
|
|
55
|
+
/** What removing asks, which says so when it is the last account. */
|
|
56
|
+
removeQuestion(row: AccountRow): string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Mode = { kind: "browse" } | { kind: "rename"; id: string } | { kind: "remove"; id: string };
|
|
60
|
+
|
|
61
|
+
interface Note {
|
|
62
|
+
text: string;
|
|
63
|
+
tone: "success" | "error";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The widths of the name and kind columns; a kind of 0 means the plan took its room. */
|
|
67
|
+
interface Columns {
|
|
68
|
+
name: number;
|
|
69
|
+
kind: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The add row's place in the list, which no account id can take. */
|
|
73
|
+
const ADD = Symbol("add");
|
|
74
|
+
type Item = string | typeof ADD;
|
|
75
|
+
|
|
76
|
+
const POINTER = "\u276f ";
|
|
77
|
+
const IN_USE = "in use";
|
|
78
|
+
const MAX_VISIBLE = 8;
|
|
79
|
+
const GAP = " ";
|
|
80
|
+
const NAME_MIN = 4;
|
|
81
|
+
const NAME_LIMIT = 24;
|
|
82
|
+
/** What the hint says enter does, by what it chooses. */
|
|
83
|
+
const ENTER_DOES: Record<AccountsChoice["kind"], string> = {
|
|
84
|
+
use: "use",
|
|
85
|
+
signIn: "sign in",
|
|
86
|
+
add: "add",
|
|
87
|
+
};
|
|
88
|
+
/** Ctrl+E, which the input reads as the end of the line: a rename starts after the old name. */
|
|
89
|
+
const LINE_END = "\u0005";
|
|
90
|
+
|
|
91
|
+
/** Opens the list, resolving with what the person chose, or undefined when it closed. */
|
|
92
|
+
export async function showAccounts(
|
|
93
|
+
ctx: ExtensionContext,
|
|
94
|
+
listing: AccountsListing,
|
|
95
|
+
ready?: Promise<unknown>,
|
|
96
|
+
): Promise<AccountsChoice | undefined> {
|
|
97
|
+
return await ctx.ui.custom<AccountsChoice | undefined>((tui, theme, _keybindings, done) => {
|
|
98
|
+
const view = new AccountsView(tui, theme, listing, done);
|
|
99
|
+
// The plans are read in the background, and their rows fill in when they arrive.
|
|
100
|
+
void ready?.then(
|
|
101
|
+
() => view.refresh(),
|
|
102
|
+
() => view.refresh(),
|
|
103
|
+
);
|
|
104
|
+
return view;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class AccountsView implements Component, Focusable {
|
|
109
|
+
private readonly input = new Input({ prompt: "" });
|
|
110
|
+
private hasFocus = false;
|
|
111
|
+
private cursor: Item;
|
|
112
|
+
private mode: Mode = { kind: "browse" };
|
|
113
|
+
private note: Note | undefined;
|
|
114
|
+
private closed = false;
|
|
115
|
+
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly tui: TUI,
|
|
118
|
+
private readonly theme: Theme,
|
|
119
|
+
private readonly listing: AccountsListing,
|
|
120
|
+
private readonly done: (choice: AccountsChoice | undefined) => void,
|
|
121
|
+
) {
|
|
122
|
+
const rows = listing.rows();
|
|
123
|
+
this.cursor = rows.find((row) => row.inUse)?.id ?? rows[0]?.id ?? ADD;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get focused(): boolean {
|
|
127
|
+
return this.hasFocus;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The field takes the focus along, so the terminal's cursor sits in a name being typed. */
|
|
131
|
+
set focused(value: boolean) {
|
|
132
|
+
this.hasFocus = value;
|
|
133
|
+
this.input.focused = value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Draws again, for a plan that arrived after the list opened. */
|
|
137
|
+
refresh(): void {
|
|
138
|
+
if (!this.closed) this.tui.requestRender();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
invalidate(): void {}
|
|
142
|
+
|
|
143
|
+
handleInput(data: string): void {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
if (this.mode.kind === "rename") this.renaming(data, this.mode.id);
|
|
146
|
+
else if (this.mode.kind === "remove") this.removing(data, this.mode.id);
|
|
147
|
+
else this.browsing(data);
|
|
148
|
+
this.tui.requestRender();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const inner = frameInner(width);
|
|
153
|
+
const rows = this.listing.rows();
|
|
154
|
+
const items = itemsOf(rows);
|
|
155
|
+
const at = this.indexOf(items);
|
|
156
|
+
const { start, end } = visible(at, items.length);
|
|
157
|
+
const states = new Map(rows.map((row) => [row.id, describeState(this.theme, row.state)]));
|
|
158
|
+
const columns = this.columns(rows, [...states.values()], inner);
|
|
159
|
+
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
if (rows.length === 0) lines.push(this.theme.fg("muted", this.listing.empty), "");
|
|
162
|
+
for (const item of items.slice(start, end)) {
|
|
163
|
+
const row = rows.find((candidate) => candidate.id === item);
|
|
164
|
+
if (row === undefined) lines.push(this.addLine());
|
|
165
|
+
else lines.push(this.accountLine(row, columns, states.get(row.id) ?? "", inner));
|
|
166
|
+
}
|
|
167
|
+
lines.push("");
|
|
168
|
+
const status = this.statusLine(rows);
|
|
169
|
+
if (status !== undefined) lines.push(status);
|
|
170
|
+
const position = start > 0 || end < items.length ? `${at + 1}/${items.length}` : undefined;
|
|
171
|
+
lines.push(this.hint(rows, position));
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
frameTop(this.theme, `${this.listing.provider} accounts`, width),
|
|
175
|
+
...lines.map((line) => frameRow(this.theme, line, inner)),
|
|
176
|
+
frameBottom(this.theme, width),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private browsing(data: string): void {
|
|
181
|
+
const rows = this.listing.rows();
|
|
182
|
+
const items = itemsOf(rows);
|
|
183
|
+
const at = this.indexOf(items);
|
|
184
|
+
const row = rows.find((candidate) => candidate.id === this.cursor);
|
|
185
|
+
|
|
186
|
+
if (pressed(data, Key.up, "k")) this.cursor = wrapped(items, at - 1);
|
|
187
|
+
else if (pressed(data, Key.down, "j")) this.cursor = wrapped(items, at + 1);
|
|
188
|
+
else if (pressed(data, Key.home)) this.cursor = wrapped(items, 0);
|
|
189
|
+
else if (pressed(data, Key.end)) this.cursor = ADD;
|
|
190
|
+
else if (pressed(data, Key.enter)) this.close(choiceOf(row));
|
|
191
|
+
else if (pressed(data, "a")) this.close({ kind: "add" });
|
|
192
|
+
else if (pressed(data, "r") && row !== undefined) {
|
|
193
|
+
this.note = undefined;
|
|
194
|
+
this.input.setValue(row.label);
|
|
195
|
+
this.input.handleInput(LINE_END);
|
|
196
|
+
this.mode = { kind: "rename", id: row.id };
|
|
197
|
+
} else if (pressed(data, "d", Key.delete) && row !== undefined) {
|
|
198
|
+
this.note = undefined;
|
|
199
|
+
this.mode = { kind: "remove", id: row.id };
|
|
200
|
+
} else if (pressed(data, Key.escape, Key.ctrl("c"), "q")) this.close(undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private renaming(data: string, id: string): void {
|
|
204
|
+
if (pressed(data, Key.escape, Key.ctrl("c"))) {
|
|
205
|
+
this.mode = { kind: "browse" };
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!pressed(data, Key.enter)) {
|
|
209
|
+
this.input.handleInput(data);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const label = this.input.getValue().trim();
|
|
213
|
+
// An empty name is no name: the field waits for one.
|
|
214
|
+
if (label === "") return;
|
|
215
|
+
const before = this.listing.rows().find((row) => row.id === id)?.label;
|
|
216
|
+
if (label !== before) {
|
|
217
|
+
const problem = this.listing.rename(id, label);
|
|
218
|
+
this.note =
|
|
219
|
+
problem === undefined
|
|
220
|
+
? { text: `Renamed to ${label}`, tone: "success" }
|
|
221
|
+
: { text: problem, tone: "error" };
|
|
222
|
+
}
|
|
223
|
+
this.mode = { kind: "browse" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private removing(data: string, id: string): void {
|
|
227
|
+
if (pressed(data, Key.escape, Key.ctrl("c"), "n")) {
|
|
228
|
+
this.mode = { kind: "browse" };
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!pressed(data, Key.enter, "y")) return;
|
|
232
|
+
|
|
233
|
+
const before = this.listing.rows();
|
|
234
|
+
const at = before.findIndex((row) => row.id === id);
|
|
235
|
+
const label = before[at]?.label ?? "";
|
|
236
|
+
const problem = this.listing.remove(id);
|
|
237
|
+
this.mode = { kind: "browse" };
|
|
238
|
+
if (problem !== undefined) {
|
|
239
|
+
this.note = { text: problem, tone: "error" };
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.note = { text: `Removed ${label}`, tone: "success" };
|
|
243
|
+
// The cursor stays where the row was, on whatever took its place.
|
|
244
|
+
const after = this.listing.rows();
|
|
245
|
+
this.cursor = after[Math.min(at, after.length - 1)]?.id ?? ADD;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private close(choice: AccountsChoice | undefined): void {
|
|
249
|
+
if (this.closed) return;
|
|
250
|
+
this.closed = true;
|
|
251
|
+
this.done(choice);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Where the cursor sits, or the first row when its account went away. */
|
|
255
|
+
private indexOf(items: readonly Item[]): number {
|
|
256
|
+
const at = items.indexOf(this.cursor);
|
|
257
|
+
if (at >= 0) return at;
|
|
258
|
+
this.cursor = items[0] ?? ADD;
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Every column as wide as its widest cell. The kind gives its room away when the plan would not fit. */
|
|
263
|
+
private columns(rows: readonly AccountRow[], states: readonly string[], inner: number): Columns {
|
|
264
|
+
const typed = this.mode.kind === "rename" ? visibleWidth(this.input.getValue()) + 1 : 0;
|
|
265
|
+
const name = Math.min(
|
|
266
|
+
NAME_LIMIT,
|
|
267
|
+
Math.max(NAME_MIN, typed, ...rows.map((row) => visibleWidth(row.label))),
|
|
268
|
+
);
|
|
269
|
+
const kind = Math.max(0, ...rows.map((row) => visibleWidth(row.kind)));
|
|
270
|
+
const state = Math.max(0, ...states.map((text) => visibleWidth(text)));
|
|
271
|
+
const fixed = POINTER.length + name + GAP.length + IN_USE.length + GAP.length;
|
|
272
|
+
const fits = fixed + kind + GAP.length + state <= inner;
|
|
273
|
+
return { name, kind: fits ? kind : 0 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private accountLine(row: AccountRow, columns: Columns, state: string, inner: number): string {
|
|
277
|
+
const theme = this.theme;
|
|
278
|
+
const chosen = row.id === this.cursor;
|
|
279
|
+
const pointer = chosen ? theme.fg("accent", POINTER) : " ".repeat(POINTER.length);
|
|
280
|
+
const name = pad(this.nameCell(row, chosen, columns.name), columns.name);
|
|
281
|
+
const kind = columns.kind === 0 ? "" : `${pad(theme.fg("dim", row.kind), columns.kind)}${GAP}`;
|
|
282
|
+
const use = pad(row.inUse ? theme.fg("success", IN_USE) : "", IN_USE.length);
|
|
283
|
+
return truncateToWidth(`${pointer}${name}${GAP}${kind}${use}${GAP}${state}`, inner);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private nameCell(row: AccountRow, chosen: boolean, width: number): string {
|
|
287
|
+
const editing = this.mode.kind !== "browse" && this.mode.id === row.id;
|
|
288
|
+
// Pi's own field draws the cursor where it is and scrolls a name longer than the column.
|
|
289
|
+
if (editing && this.mode.kind === "rename") return this.input.render(width)[0] ?? "";
|
|
290
|
+
const label = truncateToWidth(row.label, NAME_LIMIT, "…");
|
|
291
|
+
if (editing) return this.theme.fg("error", label);
|
|
292
|
+
return chosen ? this.theme.bold(label) : label;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private addLine(): string {
|
|
296
|
+
const chosen = this.cursor === ADD;
|
|
297
|
+
const label = "+ Add an account";
|
|
298
|
+
if (!chosen) return `${" ".repeat(POINTER.length)}${this.theme.fg("muted", label)}`;
|
|
299
|
+
return `${this.theme.fg("accent", POINTER)}${this.theme.bold(this.theme.fg("accent", label))}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private statusLine(rows: readonly AccountRow[]): string | undefined {
|
|
303
|
+
const mode = this.mode;
|
|
304
|
+
const removing = mode.kind === "remove" ? rows.find((row) => row.id === mode.id) : undefined;
|
|
305
|
+
if (removing !== undefined)
|
|
306
|
+
return this.theme.fg("warning", this.listing.removeQuestion(removing));
|
|
307
|
+
if (this.note === undefined) return undefined;
|
|
308
|
+
return this.theme.fg(this.note.tone, this.note.text);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private hint(rows: readonly AccountRow[], position: string | undefined): string {
|
|
312
|
+
const row = rows.find((candidate) => candidate.id === this.cursor);
|
|
313
|
+
const text = this.keys(row).map(
|
|
314
|
+
([key, action]) => `${this.theme.fg("muted", key)} ${this.theme.fg("dim", action)}`,
|
|
315
|
+
);
|
|
316
|
+
if (position !== undefined) text.push(this.theme.fg("dim", position));
|
|
317
|
+
return text.join(" ");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The keys that do something right now, and what each one does. */
|
|
321
|
+
private keys(row: AccountRow | undefined): [string, string][] {
|
|
322
|
+
if (this.mode.kind === "rename") {
|
|
323
|
+
return [
|
|
324
|
+
["enter", "save"],
|
|
325
|
+
["esc", "cancel"],
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
if (this.mode.kind === "remove") {
|
|
329
|
+
return [
|
|
330
|
+
["enter", "remove"],
|
|
331
|
+
["esc", "keep"],
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
const enter = ENTER_DOES[choiceOf(row).kind];
|
|
335
|
+
if (row === undefined) {
|
|
336
|
+
return [
|
|
337
|
+
["↑↓", "move"],
|
|
338
|
+
["enter", enter],
|
|
339
|
+
["esc", "close"],
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
return [
|
|
343
|
+
["↑↓", "move"],
|
|
344
|
+
["enter", enter],
|
|
345
|
+
["r", "rename"],
|
|
346
|
+
["d", "remove"],
|
|
347
|
+
["a", "add"],
|
|
348
|
+
["esc", "close"],
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** What enter does on a row: add on the last one, sign in on a refused account, else use it. */
|
|
354
|
+
function choiceOf(row: AccountRow | undefined): AccountsChoice {
|
|
355
|
+
if (row === undefined) return { kind: "add" };
|
|
356
|
+
if (row.state.kind === "signIn") return { kind: "signIn", id: row.id };
|
|
357
|
+
return { kind: "use", id: row.id };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The item at an index that wraps around the list. */
|
|
361
|
+
function wrapped(items: readonly Item[], index: number): Item {
|
|
362
|
+
return items[(index + items.length) % items.length] ?? ADD;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What a row says past the account's name. The Alt+A picker shows the same words. */
|
|
366
|
+
export function describeState(theme: Theme, state: AccountState): string {
|
|
367
|
+
switch (state.kind) {
|
|
368
|
+
case "quota":
|
|
369
|
+
return quotaWindows(theme, state.windows);
|
|
370
|
+
case "reading":
|
|
371
|
+
return theme.fg("dim", "reading the plan…");
|
|
372
|
+
case "signIn":
|
|
373
|
+
return theme.fg("error", "sign in again");
|
|
374
|
+
case "spent":
|
|
375
|
+
return theme.fg("warning", `spent, resets in ${state.resetsIn}`);
|
|
376
|
+
case "quiet":
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The rows' accounts in order, then the add row. */
|
|
382
|
+
function itemsOf(rows: readonly AccountRow[]): Item[] {
|
|
383
|
+
return [...rows.map((row) => row.id), ADD];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** True when the input is any of these keys. */
|
|
387
|
+
function pressed(data: string, ...keys: KeyId[]): boolean {
|
|
388
|
+
return keys.some((key) => matchesKey(data, key));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The slice of the list that fits, keeping the cursor away from the edges. */
|
|
392
|
+
function visible(at: number, count: number): { start: number; end: number } {
|
|
393
|
+
const start = Math.max(0, Math.min(at - Math.floor(MAX_VISIBLE / 2), count - MAX_VISIBLE));
|
|
394
|
+
return { start, end: Math.min(start + MAX_VISIBLE, count) };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function pad(text: string, width: number): string {
|
|
398
|
+
const clipped = truncateToWidth(text, width);
|
|
399
|
+
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
400
|
+
}
|
package/src/ui/frame.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The rounded frame every dialog of this package wears.
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
/** The columns a row's text may use, between the frame's sides and their padding. */
|
|
6
|
+
export function frameInner(width: number): number {
|
|
7
|
+
return Math.max(1, width - 4);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function frameTop(theme: Theme, title: string, width: number): string {
|
|
11
|
+
const border = (text: string) => theme.fg("border", text);
|
|
12
|
+
const room = Math.max(0, width - 5);
|
|
13
|
+
const label = theme.fg("accent", truncateToWidth(title, Math.max(0, room - 3), "…"));
|
|
14
|
+
const dashes = Math.max(0, room - visibleWidth(label));
|
|
15
|
+
return `${border("╭─ ")}${label}${border(` ${"─".repeat(dashes)}╮`)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function frameRow(theme: Theme, text: string, inner: number): string {
|
|
19
|
+
const border = theme.fg("border", "│");
|
|
20
|
+
const clipped = truncateToWidth(text, inner);
|
|
21
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
22
|
+
return `${border} ${clipped}${pad} ${border}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function frameBottom(theme: Theme, width: number): string {
|
|
26
|
+
return theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
27
|
+
}
|