ultrahope 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/git-ultrahope.js +1282 -457
  2. package/dist/index.js +1534 -533
  3. package/package.json +6 -3
@@ -1,12 +1,703 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/commands/commit.ts
3
+ // commands/commit.ts
4
4
  import { execSync as execSync2, spawn as spawn2 } from "child_process";
5
5
  import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
6
6
  import { tmpdir as tmpdir2 } from "os";
7
7
  import { join as join4 } from "path";
8
8
 
9
- // src/lib/diff-stats.ts
9
+ // lib/api-client.ts
10
+ import createClient from "openapi-fetch";
11
+
12
+ // lib/logger.ts
13
+ import { appendFileSync, mkdirSync } from "fs";
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+ var LOG_DIR = join(homedir(), ".local", "state", "ultrahope");
17
+ var LOG_FILE = join(LOG_DIR, "log");
18
+ var initialized = false;
19
+ function ensureLogDir() {
20
+ if (initialized) return;
21
+ try {
22
+ mkdirSync(LOG_DIR, { recursive: true });
23
+ initialized = true;
24
+ } catch {
25
+ }
26
+ }
27
+ function log(message, data) {
28
+ ensureLogDir();
29
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
30
+ const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}
31
+ ` : `[${timestamp}] ${message}
32
+ `;
33
+ try {
34
+ appendFileSync(LOG_FILE, line);
35
+ } catch {
36
+ }
37
+ }
38
+
39
+ // lib/api-client.ts
40
+ var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
41
+ var InsufficientBalanceError = class extends Error {
42
+ constructor(balance) {
43
+ super("Token balance exhausted");
44
+ this.balance = balance;
45
+ this.name = "InsufficientBalanceError";
46
+ }
47
+ };
48
+ var DailyLimitExceededError = class extends Error {
49
+ constructor(count, limit, resetsAt) {
50
+ super("Daily request limit reached");
51
+ this.count = count;
52
+ this.limit = limit;
53
+ this.resetsAt = resetsAt;
54
+ this.name = "DailyLimitExceededError";
55
+ }
56
+ };
57
+ var UnauthorizedError = class extends Error {
58
+ constructor() {
59
+ super("Unauthorized");
60
+ this.name = "UnauthorizedError";
61
+ }
62
+ };
63
+ async function getErrorText(response, error) {
64
+ if (error) {
65
+ try {
66
+ return JSON.stringify(error);
67
+ } catch {
68
+ return String(error);
69
+ }
70
+ }
71
+ try {
72
+ return await response.text();
73
+ } catch (readError) {
74
+ return `Failed to read response body: ${readError instanceof Error ? readError.message : String(readError)}`;
75
+ }
76
+ }
77
+ function extractGatewayMetadata(providerMetadata) {
78
+ const gateway = providerMetadata?.gateway;
79
+ if (!gateway) return {};
80
+ const generationId = typeof gateway.generationId === "string" ? gateway.generationId : void 0;
81
+ const costValue = typeof gateway.marketCost === "string" && gateway.marketCost || typeof gateway.cost === "string" && gateway.cost ? String(gateway.marketCost ?? gateway.cost) : void 0;
82
+ const cost = costValue ? Number.parseFloat(costValue) : void 0;
83
+ return { generationId, cost };
84
+ }
85
+ function parseSseEvents(buffer) {
86
+ const events = [];
87
+ let remainder = buffer;
88
+ while (true) {
89
+ const separatorIndex = remainder.indexOf("\n\n");
90
+ if (separatorIndex === -1) break;
91
+ const rawEvent = remainder.slice(0, separatorIndex);
92
+ remainder = remainder.slice(separatorIndex + 2);
93
+ const lines = rawEvent.split("\n");
94
+ for (const line of lines) {
95
+ if (!line.startsWith("data:")) continue;
96
+ const payload = line.slice(5).trim();
97
+ if (!payload) continue;
98
+ const parsed = JSON.parse(payload);
99
+ events.push(parsed);
100
+ }
101
+ }
102
+ return { events, remainder };
103
+ }
104
+ function handle402Error(error) {
105
+ const errorBalance = error?.balance;
106
+ if (typeof errorBalance === "number") {
107
+ log("generate error (402 insufficient_balance)", error);
108
+ throw new InsufficientBalanceError(errorBalance);
109
+ }
110
+ const payload = error;
111
+ const count = typeof payload?.count === "number" ? payload.count : 0;
112
+ const limit = typeof payload?.limit === "number" ? payload.limit : 0;
113
+ const resetsAt = payload?.resetsAt ?? "";
114
+ log("generate error (402 daily_limit)", error);
115
+ throw new DailyLimitExceededError(count, limit, resetsAt);
116
+ }
117
+ function createApiClient(token) {
118
+ const headers = {
119
+ "Content-Type": "application/json"
120
+ };
121
+ if (token) {
122
+ headers.Authorization = `Bearer ${token}`;
123
+ }
124
+ const client = createClient({
125
+ baseUrl: API_BASE_URL,
126
+ headers
127
+ });
128
+ return {
129
+ async *streamCommitMessage(req, options) {
130
+ log("streamCommitMessage request", req);
131
+ const res = await fetch(`${API_BASE_URL}/api/v1/commit-message/stream`, {
132
+ method: "POST",
133
+ headers: {
134
+ ...headers,
135
+ Accept: "text/event-stream"
136
+ },
137
+ body: JSON.stringify(req),
138
+ signal: options?.signal
139
+ });
140
+ if (res.status === 401) {
141
+ log("streamCommitMessage error (401)");
142
+ throw new UnauthorizedError();
143
+ }
144
+ if (res.status === 402) {
145
+ let errorPayload;
146
+ try {
147
+ errorPayload = await res.json();
148
+ } catch {
149
+ errorPayload = await getErrorText(res, null);
150
+ }
151
+ handle402Error(errorPayload);
152
+ }
153
+ if (!res.ok) {
154
+ const text = await getErrorText(res, null);
155
+ log("streamCommitMessage error", {
156
+ status: res.status,
157
+ text
158
+ });
159
+ throw new Error(`API error: ${res.status} ${text}`);
160
+ }
161
+ if (!res.body) {
162
+ throw new Error("API error: empty response body");
163
+ }
164
+ const decoder = new TextDecoder();
165
+ let buffer = "";
166
+ const reader = res.body.getReader();
167
+ try {
168
+ while (true) {
169
+ const { value, done } = await reader.read();
170
+ if (done) break;
171
+ buffer += decoder.decode(value, { stream: true });
172
+ const parsed2 = parseSseEvents(buffer);
173
+ buffer = parsed2.remainder;
174
+ for (const event of parsed2.events) {
175
+ yield event;
176
+ }
177
+ }
178
+ buffer += decoder.decode();
179
+ const parsed = parseSseEvents(buffer);
180
+ for (const event of parsed.events) {
181
+ yield event;
182
+ }
183
+ } finally {
184
+ reader.releaseLock();
185
+ }
186
+ },
187
+ async recordGenerationScore(req) {
188
+ log("generation_score request", req);
189
+ const res = await fetch(`${API_BASE_URL}/api/v1/generation_score`, {
190
+ method: "POST",
191
+ headers,
192
+ body: JSON.stringify(req)
193
+ });
194
+ if (!res.ok) {
195
+ const text = await getErrorText(res, null);
196
+ log("generation_score error", { status: res.status, text });
197
+ throw new Error(`API error: ${res.status} ${text}`);
198
+ }
199
+ },
200
+ async commandExecution(req) {
201
+ log("command_execution request", req);
202
+ const { data, error, response } = await client.POST(
203
+ "/api/v1/command_execution",
204
+ {
205
+ body: req
206
+ }
207
+ );
208
+ if (response.status === 401) {
209
+ log("command_execution error (401)", error);
210
+ throw new UnauthorizedError();
211
+ }
212
+ if (response.status === 402) {
213
+ const payload = error;
214
+ const count = typeof payload?.count === "number" ? payload.count : 0;
215
+ const limit = typeof payload?.limit === "number" ? payload.limit : 0;
216
+ const resetsAt = payload?.resetsAt ?? "";
217
+ log("command_execution error (402)", error);
218
+ throw new DailyLimitExceededError(count, limit, resetsAt);
219
+ }
220
+ if (!response.ok) {
221
+ const text = await getErrorText(response, error);
222
+ log("command_execution error", { status: response.status, text });
223
+ throw new Error(`API error: ${response.status} ${text}`);
224
+ }
225
+ if (!data) {
226
+ throw new Error("API error: empty response");
227
+ }
228
+ log("command_execution response", data);
229
+ return data;
230
+ },
231
+ async generateCommitMessage(req, options) {
232
+ log("generateCommitMessage request", req);
233
+ const { data, error, response } = await client.POST(
234
+ "/api/v1/commit-message",
235
+ {
236
+ body: req,
237
+ signal: options?.signal
238
+ }
239
+ );
240
+ if (response.status === 401) {
241
+ log("generateCommitMessage error (401)", error);
242
+ throw new UnauthorizedError();
243
+ }
244
+ if (response.status === 402) {
245
+ handle402Error(error);
246
+ }
247
+ if (!response.ok) {
248
+ const text = await getErrorText(response, error);
249
+ log("generateCommitMessage error", { status: response.status, text });
250
+ throw new Error(`API error: ${response.status} ${text}`);
251
+ }
252
+ if (!data) {
253
+ throw new Error("API error: empty response");
254
+ }
255
+ log("generateCommitMessage response", data);
256
+ return data;
257
+ },
258
+ async generateCommitMessageStream(req, options) {
259
+ let lastCommitMessage = "";
260
+ let providerMetadata;
261
+ for await (const event of this.streamCommitMessage(req, options)) {
262
+ if (event.type === "commit-message") {
263
+ lastCommitMessage = event.commitMessage;
264
+ } else if (event.type === "provider-metadata") {
265
+ providerMetadata = event.providerMetadata;
266
+ } else if (event.type === "error") {
267
+ throw new Error(event.message);
268
+ }
269
+ }
270
+ if (!lastCommitMessage) {
271
+ throw new Error("API error: empty stream response");
272
+ }
273
+ const { generationId, cost } = extractGatewayMetadata(providerMetadata);
274
+ const result = {
275
+ output: lastCommitMessage,
276
+ cost,
277
+ generationId
278
+ };
279
+ log("generateCommitMessageStream response", result);
280
+ return result;
281
+ },
282
+ async generatePrTitleBody(req, options) {
283
+ log("generatePrTitleBody request", req);
284
+ const { data, error, response } = await client.POST(
285
+ "/api/v1/pr-title-body",
286
+ {
287
+ body: req,
288
+ signal: options?.signal
289
+ }
290
+ );
291
+ if (response.status === 401) {
292
+ log("generatePrTitleBody error (401)", error);
293
+ throw new UnauthorizedError();
294
+ }
295
+ if (response.status === 402) {
296
+ handle402Error(error);
297
+ }
298
+ if (!response.ok) {
299
+ const text = await getErrorText(response, error);
300
+ log("generatePrTitleBody error", { status: response.status, text });
301
+ throw new Error(`API error: ${response.status} ${text}`);
302
+ }
303
+ if (!data) {
304
+ throw new Error("API error: empty response");
305
+ }
306
+ log("generatePrTitleBody response", data);
307
+ return data;
308
+ },
309
+ async generatePrIntent(req, options) {
310
+ log("generatePrIntent request", req);
311
+ const { data, error, response } = await client.POST("/api/v1/pr-intent", {
312
+ body: req,
313
+ signal: options?.signal
314
+ });
315
+ if (response.status === 401) {
316
+ log("generatePrIntent error (401)", error);
317
+ throw new UnauthorizedError();
318
+ }
319
+ if (response.status === 402) {
320
+ handle402Error(error);
321
+ }
322
+ if (!response.ok) {
323
+ const text = await getErrorText(response, error);
324
+ log("generatePrIntent error", { status: response.status, text });
325
+ throw new Error(`API error: ${response.status} ${text}`);
326
+ }
327
+ if (!data) {
328
+ throw new Error("API error: empty response");
329
+ }
330
+ log("generatePrIntent response", data);
331
+ return data;
332
+ },
333
+ async requestDeviceCode() {
334
+ const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
335
+ method: "POST",
336
+ headers,
337
+ body: JSON.stringify({ client_id: "ultrahope-cli" })
338
+ });
339
+ if (!res.ok) {
340
+ const text = await res.text();
341
+ throw new Error(`API error: ${res.status} ${text}`);
342
+ }
343
+ return res.json();
344
+ },
345
+ async pollDeviceToken(deviceCode) {
346
+ const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
347
+ method: "POST",
348
+ headers,
349
+ body: JSON.stringify({
350
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
351
+ device_code: deviceCode,
352
+ client_id: "ultrahope-cli"
353
+ })
354
+ });
355
+ if (!res.ok && res.status !== 400) {
356
+ const text = await res.text();
357
+ throw new Error(`API error: ${res.status} ${text}`);
358
+ }
359
+ return res.json();
360
+ }
361
+ };
362
+ }
363
+
364
+ // lib/abort.ts
365
+ function mergeAbortSignals(...signals) {
366
+ const validSignals = signals.filter(
367
+ (signal) => signal !== void 0
368
+ );
369
+ if (validSignals.length === 0) return void 0;
370
+ if (validSignals.length === 1) return validSignals[0];
371
+ const controller = new AbortController();
372
+ for (const signal of validSignals) {
373
+ if (signal.aborted) {
374
+ controller.abort(signal.reason);
375
+ break;
376
+ }
377
+ signal.addEventListener(
378
+ "abort",
379
+ () => {
380
+ controller.abort(signal.reason);
381
+ },
382
+ { once: true }
383
+ );
384
+ }
385
+ return controller.signal;
386
+ }
387
+ function commandAbortReason(signal) {
388
+ if (!signal?.aborted) return void 0;
389
+ const reason = signal.reason;
390
+ if (reason === "daily_limit" || reason === "unauthorized") {
391
+ return reason;
392
+ }
393
+ if (reason === "user" || reason === "internal") {
394
+ return reason;
395
+ }
396
+ if (reason instanceof DailyLimitExceededError) return "daily_limit";
397
+ if (reason instanceof UnauthorizedError) return "unauthorized";
398
+ return "internal";
399
+ }
400
+ function isCommandExecutionAbort(signal) {
401
+ const reason = commandAbortReason(signal);
402
+ return reason === "daily_limit" || reason === "unauthorized";
403
+ }
404
+ function abortReasonForError(error) {
405
+ if (error instanceof DailyLimitExceededError) return "daily_limit";
406
+ if (error instanceof UnauthorizedError) return "unauthorized";
407
+ return "internal";
408
+ }
409
+
410
+ // lib/auth.ts
411
+ import * as fs from "fs";
412
+ import * as os from "os";
413
+ import * as path from "path";
414
+ function getCredentialsPath() {
415
+ const configDir = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
416
+ const env = process.env.ULTRAHOPE_ENV;
417
+ const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
418
+ return path.join(configDir, "ultrahope", filename);
419
+ }
420
+ async function getToken() {
421
+ const credPath = getCredentialsPath();
422
+ try {
423
+ const content = await fs.promises.readFile(credPath, "utf-8");
424
+ const creds = JSON.parse(content);
425
+ return creds.access_token ?? null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
431
+ // lib/command-execution.ts
432
+ import { randomUUID } from "crypto";
433
+
434
+ // lib/daily-limit-prompt.ts
435
+ import { accessSync, constants, openSync } from "fs";
436
+ import * as readline from "readline";
437
+ import * as tty from "tty";
438
+ import open from "open";
439
+
440
+ // lib/format-time.ts
441
+ function formatResetTime(resetsAt) {
442
+ const resetDate = new Date(resetsAt);
443
+ const now = /* @__PURE__ */ new Date();
444
+ const diffMs = resetDate.getTime() - now.getTime();
445
+ const local = resetDate.toLocaleTimeString(void 0, {
446
+ hour: "2-digit",
447
+ minute: "2-digit",
448
+ timeZoneName: "short"
449
+ });
450
+ if (diffMs <= 0) {
451
+ return { relative: "now", local };
452
+ }
453
+ const diffMinutes = Math.ceil(diffMs / 6e4);
454
+ if (diffMinutes < 60) {
455
+ const unit2 = diffMinutes === 1 ? "minute" : "minutes";
456
+ return { relative: `in ${diffMinutes} ${unit2}`, local };
457
+ }
458
+ const diffHours = Math.ceil(diffMinutes / 60);
459
+ const unit = diffHours === 1 ? "hour" : "hours";
460
+ return { relative: `in ${diffHours} ${unit}`, local };
461
+ }
462
+
463
+ // lib/theme.ts
464
+ var isColorDisabled = process.env.NO_COLOR !== void 0 || process.env.CI !== void 0 || !process.stdout.isTTY;
465
+ function color(code) {
466
+ return isColorDisabled ? "" : code;
467
+ }
468
+ var theme = {
469
+ primary: color("\x1B[38;5;250m"),
470
+ secondary: color("\x1B[38;5;235m"),
471
+ success: color("\x1B[38;5;72m"),
472
+ progress: color("\x1B[38;5;74m"),
473
+ blocked: color("\x1B[38;5;179m"),
474
+ fatal: color("\x1B[38;5;167m"),
475
+ prompt: color("\x1B[38;5;81m"),
476
+ link: color("\x1B[38;5;81m"),
477
+ bold: color("\x1B[1m"),
478
+ dim: color("\x1B[2m"),
479
+ reset: color("\x1B[0m")
480
+ };
481
+
482
+ // lib/ui.ts
483
+ function formatTotalCost(cost) {
484
+ return `$${cost.toFixed(6)}`;
485
+ }
486
+ var ui = {
487
+ success: (msg) => `${theme.success}\u2714${theme.reset} ${theme.primary}${msg}${theme.reset}`,
488
+ progress: (msg) => `${theme.progress}\u25B6${theme.reset} ${theme.primary}${msg}${theme.reset}`,
489
+ blocked: (msg) => `${theme.blocked}!${theme.reset} ${theme.primary}${msg}${theme.reset}`,
490
+ bullet: (msg) => ` ${theme.secondary}\u2022${theme.reset} ${theme.secondary}${msg}${theme.reset}`,
491
+ prompt: (msg) => `${theme.prompt}?${theme.reset} ${msg}`,
492
+ hint: (msg) => `${theme.dim}${msg}${theme.reset}`,
493
+ bold: (msg) => `${theme.bold}${msg}${theme.reset}`,
494
+ link: (msg) => `${theme.link}${msg}${theme.reset}`
495
+ };
496
+
497
+ // lib/daily-limit-prompt.ts
498
+ var PRICING_URL = "https://ultrahope.dev/pricing";
499
+ function canUseInteractive() {
500
+ if (!process.stdout.isTTY) {
501
+ return false;
502
+ }
503
+ try {
504
+ accessSync("/dev/tty", constants.R_OK);
505
+ return true;
506
+ } catch {
507
+ return false;
508
+ }
509
+ }
510
+ async function showDailyLimitPrompt(info) {
511
+ const { relative, local } = formatResetTime(info.resetsAt);
512
+ if (info.progress) {
513
+ console.log(
514
+ ui.blocked(
515
+ `Generating commit messages... ${info.progress.ready}/${info.progress.total}`
516
+ )
517
+ );
518
+ }
519
+ console.log("");
520
+ console.log(
521
+ `${theme.primary}Commit message generation was skipped${theme.reset}`
522
+ );
523
+ console.log("");
524
+ console.log(
525
+ ui.bullet(`Daily request limit reached (${info.count} / ${info.limit})`)
526
+ );
527
+ console.log(ui.bullet(`Resets ${relative} (${local})`));
528
+ console.log("");
529
+ if (!canUseInteractive()) {
530
+ console.log(
531
+ `${theme.primary}Run the same command again after the reset:${theme.reset}`
532
+ );
533
+ console.log(` ${ui.link("ultrahope jj describe")}`);
534
+ console.log("");
535
+ console.log(`${theme.primary}Or upgrade your plan:${theme.reset}`);
536
+ console.log(` ${ui.link(PRICING_URL)}`);
537
+ return;
538
+ }
539
+ console.log(`${theme.primary}What would you like to do?${theme.reset}`);
540
+ console.log("");
541
+ console.log(
542
+ `${theme.secondary} 1) Retry after the daily limit resets${theme.reset}`
543
+ );
544
+ console.log(
545
+ `${theme.secondary} 2) Upgrade your plan to continue immediately${theme.reset}`
546
+ );
547
+ console.log("");
548
+ const choice = await promptChoice();
549
+ switch (choice) {
550
+ case "1":
551
+ handleRetryLater();
552
+ break;
553
+ case "2":
554
+ await handleUpgrade();
555
+ break;
556
+ case "q":
557
+ break;
558
+ }
559
+ }
560
+ function promptChoice() {
561
+ return new Promise((resolve) => {
562
+ const fd = openSync("/dev/tty", "r");
563
+ const ttyInput = new tty.ReadStream(fd);
564
+ const rl = readline.createInterface({
565
+ input: ttyInput,
566
+ output: process.stdout,
567
+ terminal: true
568
+ });
569
+ process.stdout.write(
570
+ `${theme.prompt}Select an option [1-2], or press q to quit:${theme.reset} `
571
+ );
572
+ readline.emitKeypressEvents(ttyInput, rl);
573
+ ttyInput.setRawMode(true);
574
+ const cleanup = () => {
575
+ ttyInput.setRawMode(false);
576
+ rl.close();
577
+ ttyInput.destroy();
578
+ console.log("");
579
+ };
580
+ const handleKeypress = (str, key) => {
581
+ if (!key && !str) return;
582
+ if (str === "q" || str === "Q" || key?.name === "q" || key?.name === "c" && key.ctrl || key?.name === "escape") {
583
+ cleanup();
584
+ resolve("q");
585
+ return;
586
+ }
587
+ if (str === "1" || key?.name === "1" || key?.sequence === "1") {
588
+ cleanup();
589
+ resolve("1");
590
+ return;
591
+ }
592
+ if (str === "2" || key?.name === "2" || key?.sequence === "2") {
593
+ cleanup();
594
+ resolve("2");
595
+ return;
596
+ }
597
+ };
598
+ ttyInput.on("keypress", handleKeypress);
599
+ });
600
+ }
601
+ function handleRetryLater() {
602
+ console.log("");
603
+ console.log(ui.success("Will retry after the daily limit resets"));
604
+ console.log(ui.bullet("No requests were sent"));
605
+ console.log("");
606
+ console.log(
607
+ `${theme.primary}Run the same command again after the reset:${theme.reset}`
608
+ );
609
+ console.log(` ${ui.link("ultrahope jj describe")}`);
610
+ console.log("");
611
+ return new Promise((resolve) => {
612
+ const fd = openSync("/dev/tty", "r");
613
+ const ttyInput = new tty.ReadStream(fd);
614
+ const rl = readline.createInterface({
615
+ input: ttyInput,
616
+ output: process.stdout,
617
+ terminal: true
618
+ });
619
+ readline.emitKeypressEvents(ttyInput, rl);
620
+ ttyInput.setRawMode(true);
621
+ const cleanup = () => {
622
+ ttyInput.setRawMode(false);
623
+ rl.close();
624
+ ttyInput.destroy();
625
+ console.log("");
626
+ };
627
+ const handleKeypress = (str, key) => {
628
+ if (!key && !str) return;
629
+ if (str === "\r" || str === "\n" || str === "q" || str === "Q" || key?.name === "return" || key?.name === "q" || key?.name === "c" && key.ctrl) {
630
+ cleanup();
631
+ resolve();
632
+ }
633
+ };
634
+ ttyInput.on("keypress", handleKeypress);
635
+ });
636
+ }
637
+ async function handleUpgrade() {
638
+ console.log("");
639
+ console.log(`${theme.primary}Opening pricing page:${theme.reset}`);
640
+ console.log(` ${ui.link(PRICING_URL)}`);
641
+ try {
642
+ await open(PRICING_URL);
643
+ } catch {
644
+ }
645
+ }
646
+
647
+ // lib/command-execution.ts
648
+ function startCommandExecution(options) {
649
+ const commandExecutionId = randomUUID();
650
+ const cliSessionId = commandExecutionId;
651
+ const abortController = new AbortController();
652
+ const commandExecutionPromise = options.api.commandExecution({
653
+ commandExecutionId,
654
+ cliSessionId,
655
+ command: options.command,
656
+ args: options.args,
657
+ api: options.apiPath,
658
+ requestPayload: options.requestPayload
659
+ });
660
+ return {
661
+ commandExecutionId,
662
+ cliSessionId,
663
+ abortController,
664
+ commandExecutionPromise
665
+ };
666
+ }
667
+ async function handleCommandExecutionError(error, options) {
668
+ if (error instanceof UnauthorizedError) {
669
+ const additionalLines = options?.additionalLinesToClear ?? 0;
670
+ if (additionalLines > 0) {
671
+ process.stdout.write(`\x1B[${additionalLines}A`);
672
+ process.stdout.write("\x1B[0J");
673
+ }
674
+ console.error(
675
+ "\x1B[31m\u2716\x1B[0m Unauthorized. Your session may have expired."
676
+ );
677
+ console.error("");
678
+ console.error(
679
+ "\x1B[2mPlease run the following command to re-authenticate:\x1B[0m"
680
+ );
681
+ console.error("");
682
+ console.error(" \x1B[36multrahope login\x1B[0m");
683
+ console.error("");
684
+ process.exit(1);
685
+ }
686
+ if (error instanceof DailyLimitExceededError) {
687
+ await showDailyLimitPrompt({
688
+ count: error.count,
689
+ limit: error.limit,
690
+ resetsAt: error.resetsAt,
691
+ progress: options?.progress
692
+ });
693
+ process.exit(1);
694
+ }
695
+ const message = error instanceof Error ? error.message : String(error);
696
+ console.error(`Error: Failed to start command execution. ${message}`);
697
+ process.exit(1);
698
+ }
699
+
700
+ // lib/diff-stats.ts
10
701
  import { execSync } from "child_process";
11
702
  function getGitStagedStats() {
12
703
  try {
@@ -38,33 +729,81 @@ function formatDiffStats(stats) {
38
729
  return parts.join(", ");
39
730
  }
40
731
 
41
- // src/lib/selector.ts
732
+ // lib/selector.ts
42
733
  import { spawn } from "child_process";
43
734
  import {
44
- accessSync,
45
- constants,
735
+ accessSync as accessSync2,
736
+ constants as constants2,
46
737
  mkdtempSync,
47
- openSync,
738
+ openSync as openSync2,
48
739
  readFileSync,
49
740
  unlinkSync,
50
741
  writeFileSync
51
742
  } from "fs";
52
743
  import { tmpdir } from "os";
53
- import { join } from "path";
54
- import * as readline from "readline";
55
- import * as tty from "tty";
56
- var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
57
- function canUseInteractive() {
58
- if (!process.stdout.isTTY) {
59
- return false;
60
- }
61
- try {
62
- accessSync("/dev/tty", constants.R_OK);
63
- return true;
64
- } catch {
65
- return false;
66
- }
744
+ import { join as join3 } from "path";
745
+ import * as readline3 from "readline";
746
+ import * as tty2 from "tty";
747
+
748
+ // lib/renderer.ts
749
+ import * as readline2 from "readline";
750
+ var SPINNER_FRAMES = [
751
+ "\u280B",
752
+ "\u2819",
753
+ "\u2839",
754
+ "\u2838",
755
+ "\u283C",
756
+ "\u2834",
757
+ "\u2826",
758
+ "\u2827",
759
+ "\u2807",
760
+ "\u280F"
761
+ ];
762
+ function isTTY(output) {
763
+ return output.isTTY === true;
67
764
  }
765
+ function createRenderer(output) {
766
+ let pendingHeight = 0;
767
+ let committedHeight = 0;
768
+ const render = (content) => {
769
+ if (!isTTY(output)) {
770
+ output.write(content);
771
+ return;
772
+ }
773
+ if (pendingHeight > 0) {
774
+ readline2.moveCursor(output, 0, -pendingHeight);
775
+ readline2.cursorTo(output, 0);
776
+ readline2.clearScreenDown(output);
777
+ }
778
+ output.write(content);
779
+ pendingHeight = content.split("\n").length - 1;
780
+ };
781
+ const flush = () => {
782
+ committedHeight += pendingHeight;
783
+ pendingHeight = 0;
784
+ };
785
+ const clearAll = () => {
786
+ if (!isTTY(output)) {
787
+ return;
788
+ }
789
+ const totalHeight = pendingHeight + committedHeight;
790
+ if (totalHeight > 0) {
791
+ readline2.moveCursor(output, 0, -totalHeight);
792
+ readline2.cursorTo(output, 0);
793
+ readline2.clearScreenDown(output);
794
+ }
795
+ pendingHeight = 0;
796
+ committedHeight = 0;
797
+ };
798
+ const reset = () => {
799
+ pendingHeight = 0;
800
+ committedHeight = 0;
801
+ };
802
+ return { render, flush, clearAll, reset };
803
+ }
804
+
805
+ // lib/selector.ts
806
+ var TTY_PATH = "/dev/tty";
68
807
  function formatModelName(model) {
69
808
  const parts = model.split("/");
70
809
  return parts.length > 1 ? parts[1] : model;
@@ -72,48 +811,94 @@ function formatModelName(model) {
72
811
  function formatCost(cost) {
73
812
  return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
74
813
  }
814
+ function getReadyCount(slots) {
815
+ return slots.filter((s) => s.status === "ready").length;
816
+ }
817
+ function getTotalCost(slots) {
818
+ return slots.reduce((sum, slot) => {
819
+ if (slot.status === "ready" && slot.candidate.cost != null) {
820
+ return sum + slot.candidate.cost;
821
+ }
822
+ return sum;
823
+ }, 0);
824
+ }
825
+ function getLatestQuota(slots) {
826
+ for (const slot of slots) {
827
+ if (slot.status === "ready" && slot.candidate.quota) {
828
+ return slot.candidate.quota;
829
+ }
830
+ }
831
+ return void 0;
832
+ }
833
+ function hasReadySlot(slots) {
834
+ return slots.some((s) => s.status === "ready");
835
+ }
836
+ function getSelectedCandidate(slots, selectedIndex) {
837
+ const slot = slots[selectedIndex];
838
+ return slot?.status === "ready" ? slot.candidate : void 0;
839
+ }
840
+ function selectNearestReady(slots, startIndex, direction) {
841
+ for (let i = startIndex + direction; i >= 0 && i < slots.length; i += direction) {
842
+ if (slots[i]?.status === "ready") {
843
+ return i;
844
+ }
845
+ }
846
+ return startIndex;
847
+ }
848
+ function collapseToReady(slots) {
849
+ const readySlots = slots.filter((s) => s.status === "ready");
850
+ slots.length = 0;
851
+ for (const slot of readySlots) {
852
+ slots.push(slot);
853
+ }
854
+ }
75
855
  function formatSlot(slot, selected) {
76
- const radio = selected ? "\u25CF" : "\u25CB";
77
856
  if (slot.status === "pending") {
78
- return [];
857
+ const radio2 = "\u25CB";
858
+ const line2 = `${theme.dim} ${radio2} Generating...${theme.reset}`;
859
+ const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
860
+ return meta2 ? [line2, meta2] : [line2];
79
861
  }
80
862
  const candidate = slot.candidate;
81
863
  const title = candidate.content.split("\n")[0]?.trim() || "";
82
864
  const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
83
865
  if (selected) {
84
- const line2 = ` ${radio} \x1B[1m${title}\x1B[0m`;
85
- const meta2 = modelInfo ? ` \x1B[36m${modelInfo}\x1B[0m` : "";
866
+ const radio2 = "\u25CF";
867
+ const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
868
+ const meta2 = modelInfo ? ` ${theme.progress}${modelInfo}${theme.reset}` : "";
86
869
  return meta2 ? [line2, meta2] : [line2];
87
870
  }
88
- const line = `\x1B[2m ${radio} ${title}\x1B[0m`;
89
- const meta = modelInfo ? `\x1B[2m ${modelInfo}\x1B[0m` : "";
871
+ const radio = "\u25CB";
872
+ const line = `${theme.dim} ${radio} ${title}${theme.reset}`;
873
+ const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
90
874
  return meta ? [line, meta] : [line];
91
875
  }
92
- var lastRenderLineCount = 0;
93
- function render(state) {
94
- const { slots, selectedIndex, isGenerating, spinnerFrame, totalSlots } = state;
95
- if (lastRenderLineCount > 0) {
96
- process.stdout.write(`\x1B[${lastRenderLineCount}A`);
97
- process.stdout.write("\x1B[0J");
98
- }
876
+ function formatTotalCostLabel(cost) {
877
+ return `$${cost.toFixed(6)}`;
878
+ }
879
+ function renderSelector(state, nowMs, renderer) {
880
+ const { slots, selectedIndex, isGenerating, totalSlots } = state;
99
881
  const lines = [];
100
- const readyCount = slots.filter((s) => s.status === "ready").length;
882
+ const readyCount = getReadyCount(slots);
883
+ const totalCost = getTotalCost(slots);
884
+ const costSuffix = totalCost > 0 ? ` (total: ${formatTotalCostLabel(totalCost)})` : "";
101
885
  if (isGenerating) {
102
- const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
886
+ const frameIndex = Math.floor(nowMs / 80) % SPINNER_FRAMES.length;
887
+ const spinner = SPINNER_FRAMES[frameIndex];
103
888
  const progress = `${readyCount}/${totalSlots}`;
104
889
  lines.push(
105
- `\x1B[33m${spinner}\x1B[0m Generating commit messages... ${progress}`
890
+ `${theme.progress}${spinner}${theme.reset} ${theme.primary}Generating commit messages... ${progress}${costSuffix}${theme.reset}`
106
891
  );
107
892
  } else {
108
- const label = readyCount === 1 ? "1 commit message generated" : `${readyCount} commit messages generated`;
109
- lines.push(`\x1B[32m\u2714\x1B[0m ${label}`);
893
+ const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
894
+ lines.push(ui.success(label));
110
895
  }
111
896
  const hasReady = readyCount > 0;
112
897
  if (hasReady) {
113
- const hint = "\x1B[2m\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit\x1B[0m";
114
- lines.push(`\x1B[36m?\x1B[0m Select a commit message ${hint}`);
898
+ const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
899
+ lines.push(ui.prompt(`Select a commit message ${hint}`));
115
900
  } else {
116
- lines.push("\x1B[2m q quit\x1B[0m");
901
+ lines.push(ui.hint(" q quit"));
117
902
  }
118
903
  lines.push("");
119
904
  for (let i = 0; i < slots.length; i++) {
@@ -125,16 +910,27 @@ function render(state) {
125
910
  lines.push("");
126
911
  }
127
912
  }
913
+ renderer.render(`${lines.join("\n")}
914
+ `);
915
+ }
916
+ function renderError(error, slots, totalSlots, output) {
917
+ const readyCount = slots.filter((s) => s.status === "ready").length;
918
+ const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
919
+ const lines = [
920
+ ui.blocked(`Generating commit messages... ${readyCount}/${totalSlots}`),
921
+ "",
922
+ `${theme.fatal}Error: ${message}${theme.reset}`
923
+ ];
128
924
  for (const line of lines) {
129
- console.log(line);
925
+ output.write(`${line}
926
+ `);
130
927
  }
131
- lastRenderLineCount = lines.length;
132
928
  }
133
929
  function openEditor(content) {
134
930
  return new Promise((resolve, reject) => {
135
931
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
136
- const tmpDir = mkdtempSync(join(tmpdir(), "ultrahope-"));
137
- const tmpFile = join(tmpDir, "EDIT_MESSAGE");
932
+ const tmpDir = mkdtempSync(join3(tmpdir(), "ultrahope-"));
933
+ const tmpFile = join3(tmpDir, "EDIT_MESSAGE");
138
934
  writeFileSync(tmpFile, content);
139
935
  const child = spawn(editor, [tmpFile], { stdio: "inherit" });
140
936
  child.on("close", (code) => {
@@ -157,462 +953,439 @@ function openEditor(content) {
157
953
  });
158
954
  }
159
955
  async function selectCandidate(options) {
160
- const { candidates, maxSlots = 4 } = options;
161
- if (Array.isArray(candidates)) {
162
- if (!canUseInteractive() || candidates.length === 0) {
163
- return {
164
- action: "confirm",
165
- selected: candidates[0]?.content,
166
- selectedIndex: 0
167
- };
956
+ const { createCandidates, maxSlots = 4, abortSignal, models } = options;
957
+ const abortController = new AbortController();
958
+ if (abortSignal?.aborted) {
959
+ abortController.abort();
960
+ return { action: "abort" };
961
+ }
962
+ if (abortSignal) {
963
+ abortSignal.addEventListener("abort", () => abortController.abort(), {
964
+ once: true
965
+ });
966
+ }
967
+ const candidates = createCandidates(abortController.signal);
968
+ let ttyInput = null;
969
+ let ttyOutput = null;
970
+ try {
971
+ accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
972
+ const inputFd = openSync2(TTY_PATH, "r");
973
+ ttyInput = new tty2.ReadStream(inputFd);
974
+ if (process.stdout.isTTY) {
975
+ ttyOutput = process.stdout;
976
+ } else {
977
+ const outputFd = openSync2(TTY_PATH, "w");
978
+ ttyOutput = new tty2.WriteStream(outputFd);
168
979
  }
169
- const slots2 = candidates.map((c) => ({
170
- status: "ready",
171
- candidate: c
172
- }));
173
- return selectFromSlots(slots2, null);
980
+ } catch {
981
+ console.error(
982
+ "Error: /dev/tty is not available. Use --no-interactive for non-interactive mode."
983
+ );
984
+ process.exit(1);
174
985
  }
175
- if (!canUseInteractive()) {
176
- const first = await (async () => {
177
- for await (const c of candidates) return c;
178
- return void 0;
179
- })();
180
- return {
181
- action: "confirm",
182
- selected: first?.content,
183
- selectedIndex: 0
184
- };
986
+ if (!ttyInput || !ttyOutput) {
987
+ console.error(
988
+ "Error: /dev/tty is not available. Use --no-interactive for non-interactive mode."
989
+ );
990
+ process.exit(1);
185
991
  }
186
- const slots = Array.from({ length: maxSlots }, () => ({
187
- status: "pending"
992
+ const slots = Array.from({ length: maxSlots }, (_, i) => ({
993
+ status: "pending",
994
+ slotId: models?.[i] ?? `slot-${i}`,
995
+ model: models?.[i]
188
996
  }));
189
- const abortController = new AbortController();
190
- return selectFromSlots(slots, { candidates, abortController });
997
+ return selectFromSlots(
998
+ slots,
999
+ { candidates, abortController, abortSignal },
1000
+ { input: ttyInput, output: ttyOutput }
1001
+ );
191
1002
  }
192
- async function selectFromSlots(initialSlots, asyncCtx) {
1003
+ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
193
1004
  return new Promise((resolve) => {
194
- let selectedIndex = 0;
1005
+ let resolved = false;
1006
+ const resolveOnce = (result) => {
1007
+ if (resolved) return;
1008
+ resolved = true;
1009
+ resolve(result);
1010
+ };
195
1011
  const slots = [...initialSlots];
196
- const totalSlots = initialSlots.length;
197
- let isGenerating = asyncCtx !== null;
198
- let spinnerFrame = 0;
199
- let spinnerInterval = null;
200
- const fd = openSync("/dev/tty", "r");
201
- const ttyInput = new tty.ReadStream(fd);
202
- const rl = readline.createInterface({
1012
+ const state = {
1013
+ slots,
1014
+ selectedIndex: 0,
1015
+ isGenerating: asyncCtx !== null,
1016
+ totalSlots: initialSlots.length
1017
+ };
1018
+ let renderInterval = null;
1019
+ let cleanedUp = false;
1020
+ const ttyInput = ttyIo.input;
1021
+ const ttyOutput = ttyIo.output;
1022
+ const renderer = createRenderer(ttyOutput);
1023
+ const rl = readline3.createInterface({
203
1024
  input: ttyInput,
204
- output: process.stdout,
1025
+ output: ttyOutput,
205
1026
  terminal: true
206
1027
  });
207
- readline.emitKeypressEvents(ttyInput, rl);
1028
+ readline3.emitKeypressEvents(ttyInput, rl);
208
1029
  ttyInput.setRawMode(true);
209
1030
  const doRender = () => {
210
- render({ slots, selectedIndex, isGenerating, spinnerFrame, totalSlots });
1031
+ if (!cleanedUp) {
1032
+ renderSelector(state, Date.now(), renderer);
1033
+ }
211
1034
  };
212
- doRender();
213
- if (isGenerating) {
214
- spinnerInterval = setInterval(() => {
215
- spinnerFrame++;
1035
+ const updateState = (update) => {
1036
+ update(state);
1037
+ doRender();
1038
+ };
1039
+ const startRenderLoop = () => {
1040
+ if (!state.isGenerating) return;
1041
+ renderInterval = setInterval(() => {
216
1042
  doRender();
217
1043
  }, 80);
218
- }
219
- const cleanup = () => {
1044
+ };
1045
+ const stopRenderLoop = () => {
1046
+ if (!renderInterval) return;
1047
+ clearInterval(renderInterval);
1048
+ renderInterval = null;
1049
+ };
1050
+ doRender();
1051
+ startRenderLoop();
1052
+ const cancelGeneration = () => {
220
1053
  asyncCtx?.abortController.abort();
221
- if (spinnerInterval) {
222
- clearInterval(spinnerInterval);
223
- spinnerInterval = null;
1054
+ };
1055
+ const cleanup = (clearOutput = true) => {
1056
+ if (cleanedUp) return;
1057
+ cleanedUp = true;
1058
+ stopRenderLoop();
1059
+ if (clearOutput) {
1060
+ renderer.clearAll();
224
1061
  }
225
1062
  ttyInput.setRawMode(false);
226
1063
  rl.close();
227
1064
  ttyInput.destroy();
228
- if (lastRenderLineCount > 0) {
229
- process.stdout.write(`\x1B[${lastRenderLineCount}A`);
230
- process.stdout.write("\x1B[0J");
231
- }
232
- lastRenderLineCount = 0;
1065
+ ttyOutput.destroy();
1066
+ };
1067
+ const nextCandidate = async (iterator) => {
1068
+ const abortPromise = new Promise(
1069
+ (resolve2) => {
1070
+ asyncCtx?.abortController.signal.addEventListener(
1071
+ "abort",
1072
+ () => resolve2({ done: true, value: void 0 }),
1073
+ { once: true }
1074
+ );
1075
+ }
1076
+ );
1077
+ return Promise.race([iterator.next(), abortPromise]);
1078
+ };
1079
+ const finalizeGeneration = () => {
1080
+ collapseToReady(slots);
1081
+ stopRenderLoop();
1082
+ updateState((draft) => {
1083
+ if (draft.selectedIndex >= slots.length) {
1084
+ draft.selectedIndex = Math.max(0, slots.length - 1);
1085
+ }
1086
+ draft.isGenerating = false;
1087
+ });
233
1088
  };
234
1089
  if (asyncCtx) {
1090
+ const iterator = asyncCtx.candidates[Symbol.asyncIterator]();
235
1091
  (async () => {
236
- let i = 0;
237
1092
  try {
238
- for await (const candidate of asyncCtx.candidates) {
239
- if (asyncCtx.abortController.signal.aborted) break;
240
- if (i < slots.length) {
241
- slots[i] = { status: "ready", candidate };
242
- if (selectedIndex >= slots.length || slots[selectedIndex].status === "pending") {
243
- selectedIndex = i;
244
- }
245
- doRender();
246
- i++;
1093
+ while (!cleanedUp) {
1094
+ const result = await nextCandidate(iterator);
1095
+ if (result.done || cleanedUp) break;
1096
+ const candidate = result.value;
1097
+ const targetIndex = slots.findIndex(
1098
+ (slot) => slot.status === "pending" ? slot.slotId === candidate.slotId : slot.candidate.slotId === candidate.slotId
1099
+ );
1100
+ if (targetIndex >= 0 && targetIndex < slots.length) {
1101
+ const isNewSlot = slots[targetIndex].status === "pending";
1102
+ updateState((draft) => {
1103
+ slots[targetIndex] = { status: "ready", candidate };
1104
+ if (isNewSlot && (draft.selectedIndex >= slots.length || slots[draft.selectedIndex].status === "pending")) {
1105
+ draft.selectedIndex = targetIndex;
1106
+ }
1107
+ });
247
1108
  }
248
1109
  }
249
- const readySlots = slots.filter((s) => s.status === "ready");
250
- slots.length = readySlots.length;
251
- for (let j = 0; j < readySlots.length; j++) {
252
- slots[j] = readySlots[j];
253
- }
254
- if (selectedIndex >= slots.length) {
255
- selectedIndex = Math.max(0, slots.length - 1);
256
- }
257
- isGenerating = false;
258
- if (spinnerInterval) {
259
- clearInterval(spinnerInterval);
260
- spinnerInterval = null;
261
- }
262
- doRender();
1110
+ if (cleanedUp) return;
1111
+ finalizeGeneration();
263
1112
  } catch (err) {
264
- if (!asyncCtx.abortController.signal.aborted) {
265
- console.error("Error fetching candidates:", err);
1113
+ if (asyncCtx?.abortController.signal.aborted || err instanceof Error && err.name === "AbortError") {
1114
+ return;
266
1115
  }
1116
+ if (!cleanedUp) {
1117
+ cancelGeneration();
1118
+ renderer.clearAll();
1119
+ renderError(err, slots, state.totalSlots, ttyOutput);
1120
+ cleanup(false);
1121
+ resolveOnce({ action: "abort" });
1122
+ }
1123
+ } finally {
1124
+ iterator.return?.();
267
1125
  }
268
1126
  })();
269
1127
  }
270
- const getSelectedCandidate = () => {
271
- const slot = slots[selectedIndex];
272
- return slot?.status === "ready" ? slot.candidate : void 0;
1128
+ const confirmSelection = () => {
1129
+ const candidate = getSelectedCandidate(slots, state.selectedIndex);
1130
+ if (!candidate) return;
1131
+ cancelGeneration();
1132
+ const totalCost = getTotalCost(slots);
1133
+ const quota = getLatestQuota(slots);
1134
+ resolveOnce({
1135
+ action: "confirm",
1136
+ selected: candidate.content,
1137
+ selectedIndex: state.selectedIndex,
1138
+ selectedCandidate: candidate,
1139
+ totalCost: totalCost > 0 ? totalCost : void 0,
1140
+ quota
1141
+ });
1142
+ cleanup();
1143
+ };
1144
+ const rerollSelection = () => {
1145
+ if (!hasReadySlot(slots)) return;
1146
+ cancelGeneration();
1147
+ resolveOnce({ action: "reroll" });
1148
+ cleanup();
1149
+ };
1150
+ const abortSelection = () => {
1151
+ cancelGeneration();
1152
+ cleanup();
1153
+ resolveOnce({ action: "abort" });
1154
+ };
1155
+ const editSelection = async () => {
1156
+ const candidate = getSelectedCandidate(slots, state.selectedIndex);
1157
+ if (!candidate) return;
1158
+ renderer.flush();
1159
+ ttyInput.setRawMode(false);
1160
+ let edited = null;
1161
+ try {
1162
+ const result = await openEditor(candidate.content);
1163
+ edited = result ? result : null;
1164
+ } catch {
1165
+ }
1166
+ ttyInput.setRawMode(true);
1167
+ renderer.reset();
1168
+ updateState(() => {
1169
+ if (!edited) return;
1170
+ slots[state.selectedIndex] = {
1171
+ status: "ready",
1172
+ candidate: { ...candidate, content: edited }
1173
+ };
1174
+ });
273
1175
  };
274
- const hasReadySlot = () => slots.some((s) => s.status === "ready");
275
1176
  const handleKeypress = async (_str, key) => {
276
1177
  if (!key) return;
277
1178
  if (key.name === "q" || key.name === "c" && key.ctrl || key.name === "escape") {
278
- cleanup();
279
- resolve({ action: "abort" });
1179
+ abortSelection();
280
1180
  return;
281
1181
  }
282
1182
  if (key.name === "return") {
283
- const candidate = getSelectedCandidate();
284
- if (!candidate) return;
285
- cleanup();
286
- resolve({
287
- action: "confirm",
288
- selected: candidate.content,
289
- selectedIndex
290
- });
1183
+ confirmSelection();
291
1184
  return;
292
1185
  }
293
1186
  if (key.name === "r") {
294
- if (!hasReadySlot()) return;
295
- cleanup();
296
- resolve({ action: "reroll" });
1187
+ rerollSelection();
297
1188
  return;
298
1189
  }
299
1190
  if (key.name === "e") {
300
- const candidate = getSelectedCandidate();
301
- if (!candidate) return;
302
- ttyInput.setRawMode(false);
303
- try {
304
- const edited = await openEditor(candidate.content);
305
- if (edited) {
306
- slots[selectedIndex] = {
307
- status: "ready",
308
- candidate: { ...candidate, content: edited }
309
- };
310
- }
311
- } catch {
312
- }
313
- ttyInput.setRawMode(true);
314
- doRender();
1191
+ await editSelection();
315
1192
  return;
316
1193
  }
317
1194
  if (key.name === "up" || key.name === "k") {
318
- for (let i = selectedIndex - 1; i >= 0; i--) {
319
- if (slots[i]?.status === "ready") {
320
- selectedIndex = i;
321
- doRender();
322
- break;
323
- }
324
- }
1195
+ updateState((draft) => {
1196
+ draft.selectedIndex = selectNearestReady(
1197
+ slots,
1198
+ draft.selectedIndex,
1199
+ -1
1200
+ );
1201
+ });
325
1202
  return;
326
1203
  }
327
1204
  if (key.name === "down" || key.name === "j") {
328
- for (let i = selectedIndex + 1; i < slots.length; i++) {
329
- if (slots[i]?.status === "ready") {
330
- selectedIndex = i;
331
- doRender();
332
- break;
333
- }
334
- }
1205
+ updateState((draft) => {
1206
+ draft.selectedIndex = selectNearestReady(
1207
+ slots,
1208
+ draft.selectedIndex,
1209
+ 1
1210
+ );
1211
+ });
335
1212
  return;
336
1213
  }
337
1214
  const num = Number.parseInt(key.name || "", 10);
338
1215
  if (num >= 1 && num <= slots.length && slots[num - 1]?.status === "ready") {
339
- selectedIndex = num - 1;
340
- doRender();
1216
+ updateState((draft) => {
1217
+ draft.selectedIndex = num - 1;
1218
+ });
341
1219
  return;
342
1220
  }
343
1221
  };
344
- ttyInput.on("keypress", handleKeypress);
345
- });
346
- }
347
-
348
- // src/lib/api-client.ts
349
- import createClient from "openapi-fetch";
350
-
351
- // src/lib/logger.ts
352
- import { appendFileSync, mkdirSync } from "fs";
353
- import { homedir } from "os";
354
- import { join as join2 } from "path";
355
- var LOG_DIR = join2(homedir(), ".local", "state", "ultrahope");
356
- var LOG_FILE = join2(LOG_DIR, "log");
357
- var initialized = false;
358
- function ensureLogDir() {
359
- if (initialized) return;
360
- try {
361
- mkdirSync(LOG_DIR, { recursive: true });
362
- initialized = true;
363
- } catch {
364
- }
365
- }
366
- function log(message, data) {
367
- ensureLogDir();
368
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
369
- const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}
370
- ` : `[${timestamp}] ${message}
371
- `;
372
- try {
373
- appendFileSync(LOG_FILE, line);
374
- } catch {
375
- }
376
- }
377
-
378
- // src/lib/api-client.ts
379
- var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
380
- var InsufficientBalanceError = class extends Error {
381
- constructor(balance) {
382
- super("Token balance exhausted");
383
- this.balance = balance;
384
- this.name = "InsufficientBalanceError";
385
- }
386
- };
387
- function createApiClient(token) {
388
- const headers = {
389
- "Content-Type": "application/json"
390
- };
391
- if (token) {
392
- headers.Authorization = `Bearer ${token}`;
393
- }
394
- const client = createClient({
395
- baseUrl: API_BASE_URL,
396
- headers
397
- });
398
- return {
399
- async translate(req) {
400
- log("translate request", req);
401
- const { data, error, response } = await client.POST("/api/v1/translate", {
402
- body: req
403
- });
404
- if (response.status === 402) {
405
- const errorBalance = error?.balance;
406
- const balance = typeof errorBalance === "number" ? errorBalance : 0;
407
- log("translate error (402)", error);
408
- throw new InsufficientBalanceError(balance);
409
- }
410
- if (!response.ok) {
411
- const text = await response.text();
412
- log("translate error", { status: response.status, text });
413
- throw new Error(`API error: ${response.status} ${text}`);
414
- }
415
- if (!data) {
416
- throw new Error("API error: empty response");
417
- }
418
- log("translate response", data);
419
- return data;
420
- },
421
- async requestDeviceCode() {
422
- const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
423
- method: "POST",
424
- headers,
425
- body: JSON.stringify({ client_id: "ultrahope-cli" })
426
- });
427
- if (!res.ok) {
428
- const text = await res.text();
429
- throw new Error(`API error: ${res.status} ${text}`);
430
- }
431
- return res.json();
432
- },
433
- async pollDeviceToken(deviceCode) {
434
- const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
435
- method: "POST",
436
- headers,
437
- body: JSON.stringify({
438
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
439
- device_code: deviceCode,
440
- client_id: "ultrahope-cli"
441
- })
442
- });
443
- if (!res.ok && res.status !== 400) {
444
- const text = await res.text();
445
- throw new Error(`API error: ${res.status} ${text}`);
1222
+ if (asyncCtx?.abortSignal) {
1223
+ if (asyncCtx.abortSignal.aborted) {
1224
+ abortSelection();
1225
+ } else {
1226
+ asyncCtx.abortSignal.addEventListener("abort", abortSelection, {
1227
+ once: true
1228
+ });
446
1229
  }
447
- return res.json();
448
- }
449
- };
450
- }
451
-
452
- // src/lib/auth.ts
453
- import * as fs from "fs";
454
- import * as os from "os";
455
- import * as path from "path";
456
- function getCredentialsPath() {
457
- const configDir = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
458
- const env = process.env.ULTRAHOPE_ENV;
459
- const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
460
- return path.join(configDir, "ultrahope", filename);
461
- }
462
- async function getToken() {
463
- const credPath = getCredentialsPath();
464
- try {
465
- const content = await fs.promises.readFile(credPath, "utf-8");
466
- const creds = JSON.parse(content);
467
- return creds.access_token ?? null;
468
- } catch {
469
- return null;
470
- }
471
- }
472
-
473
- // src/lib/mock-api-client.ts
474
- var MOCK_COMMIT_MESSAGES = [
475
- "feat: implement new feature with improved performance",
476
- "fix: resolve edge case in data processing logic",
477
- "refactor: simplify code structure for better maintainability",
478
- "docs: update README with usage examples",
479
- "chore: update dependencies to latest versions",
480
- "test: add unit tests for core functionality",
481
- "style: format code according to style guide",
482
- "perf: optimize algorithm for faster execution"
483
- ];
484
- var MOCK_PR_TITLES = [
485
- "Add new authentication flow",
486
- "Fix memory leak in worker process",
487
- "Refactor database connection handling",
488
- "Update documentation for API endpoints"
489
- ];
490
- var MOCK_PR_BODIES = [
491
- `## Summary
492
- This PR adds a new authentication flow that improves security and user experience.
493
-
494
- ## Changes
495
- - Added OAuth2 support
496
- - Implemented token refresh logic
497
- - Updated session management
498
-
499
- ## Testing
500
- - Added unit tests for auth module
501
- - Tested manually with staging environment`
502
- ];
503
- function getMockOutput(target) {
504
- let pool;
505
- switch (target) {
506
- case "vcs-commit-message":
507
- pool = MOCK_COMMIT_MESSAGES;
508
- break;
509
- case "pr-title-body":
510
- pool = MOCK_PR_TITLES.map(
511
- (title, i) => `${title}
512
-
513
- ${MOCK_PR_BODIES[i % MOCK_PR_BODIES.length]}`
514
- );
515
- break;
516
- case "pr-intent":
517
- pool = MOCK_PR_TITLES;
518
- break;
519
- default:
520
- pool = MOCK_COMMIT_MESSAGES;
521
- }
522
- const shuffled = [...pool].sort(() => Math.random() - 0.5);
523
- return shuffled[0];
524
- }
525
- function createMockApiClient() {
526
- return {
527
- async translate(req) {
528
- await new Promise((resolve) => setTimeout(resolve, 100));
529
- const output = getMockOutput(req.target);
530
- return {
531
- output,
532
- content: output,
533
- vendor: "mock",
534
- model: "mock/mock-model",
535
- inputTokens: 100,
536
- outputTokens: 50,
537
- cost: 1e-3
538
- };
539
- },
540
- async requestDeviceCode() {
541
- return {
542
- device_code: "mock-device-code",
543
- user_code: "MOCK-1234",
544
- verification_uri: "https://ultrahope.dev/device",
545
- expires_in: 900,
546
- interval: 5
547
- };
548
- },
549
- async pollDeviceToken(_deviceCode) {
550
- return {
551
- access_token: "mock-access-token",
552
- token_type: "Bearer"
553
- };
554
1230
  }
555
- };
1231
+ ttyInput.on("keypress", handleKeypress);
1232
+ });
556
1233
  }
557
1234
 
558
- // src/lib/vcs-message-generator.ts
1235
+ // lib/vcs-message-generator.ts
559
1236
  var DEFAULT_MODELS = [
560
- "mistral/mistral-nemo",
561
- "cerebras/llama-3.1-8b",
562
- "openai/gpt-5-nano",
1237
+ // "mistral/ministral-3b",
1238
+ // "cerebras/qwen-3-235b",
1239
+ // "openai/gpt-5.1",
1240
+ "mistral/ministral-3b",
563
1241
  "xai/grok-code-fast-1"
564
1242
  ];
565
- async function* generateCommitMessages(options) {
566
- const { diff, models, mock = false } = options;
567
- if (mock) {
568
- const api2 = createMockApiClient();
569
- for (const model of models) {
570
- const result = await api2.translate({
571
- input: diff,
572
- model,
573
- target: "vcs-commit-message"
574
- });
575
- yield { content: result.output, model };
576
- }
1243
+ var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
1244
+ var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
1245
+ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1246
+ var createAbortPromise = (signal) => signal ? new Promise((resolve) => {
1247
+ if (signal.aborted) {
1248
+ resolve(null);
577
1249
  return;
578
1250
  }
1251
+ signal.addEventListener("abort", () => resolve(null), { once: true });
1252
+ }) : null;
1253
+ async function* generateCommitMessages(options) {
1254
+ const {
1255
+ diff,
1256
+ models,
1257
+ signal,
1258
+ cliSessionId,
1259
+ commandExecutionPromise,
1260
+ useStream = false
1261
+ } = options;
1262
+ const resolvedCliSessionId = cliSessionId;
1263
+ if (!resolvedCliSessionId) {
1264
+ throw new Error("Missing cliSessionId for generate request.");
1265
+ }
1266
+ const requiredCliSessionId = resolvedCliSessionId;
579
1267
  const token = await getToken();
580
1268
  if (!token) {
581
1269
  console.error("Error: Not authenticated. Run `ultrahope login` first.");
582
1270
  process.exit(1);
583
1271
  }
584
1272
  const api = createApiClient(token);
585
- const pending = models.map((model, index) => ({
586
- promise: (async () => {
1273
+ const generateWithRetry = async function* (payload) {
1274
+ const maxAttempts = 3;
1275
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
587
1276
  try {
588
- const result = await api.translate({
589
- input: diff,
590
- model,
591
- target: "vcs-commit-message"
592
- });
593
- return {
594
- result: {
595
- content: result.output,
596
- model,
597
- cost: result.cost
598
- },
599
- index
600
- };
1277
+ for await (const event of api.streamCommitMessage(payload, {
1278
+ signal
1279
+ })) {
1280
+ log("generate", event);
1281
+ yield event;
1282
+ }
1283
+ return;
601
1284
  } catch (error) {
602
- if (error instanceof InsufficientBalanceError) {
603
- throw error;
1285
+ if (signal?.aborted || isAbortError(error)) throw error;
1286
+ if (isInvalidCliSessionIdError(error)) {
1287
+ if (commandExecutionPromise) {
1288
+ try {
1289
+ await commandExecutionPromise;
1290
+ continue;
1291
+ } catch {
1292
+ const abortError = new Error("Aborted");
1293
+ abortError.name = "AbortError";
1294
+ throw abortError;
1295
+ }
1296
+ }
1297
+ if (attempt < maxAttempts - 1) {
1298
+ await delay(80 * (attempt + 1));
1299
+ continue;
1300
+ }
1301
+ }
1302
+ throw error;
1303
+ }
1304
+ }
1305
+ throw new Error("Failed to generate after retries.");
1306
+ };
1307
+ async function* generateForModel(model, slotIndex) {
1308
+ try {
1309
+ if (signal?.aborted) return;
1310
+ let lastCommitMessage = "";
1311
+ let providerMetadata;
1312
+ for await (const event of generateWithRetry({
1313
+ cliSessionId: requiredCliSessionId,
1314
+ input: diff,
1315
+ model
1316
+ })) {
1317
+ if (event.type === "commit-message") {
1318
+ lastCommitMessage = event.commitMessage;
1319
+ if (useStream) {
1320
+ yield {
1321
+ content: lastCommitMessage,
1322
+ slotId: model,
1323
+ model,
1324
+ isPartial: true,
1325
+ slotIndex
1326
+ };
1327
+ }
1328
+ } else if (event.type === "provider-metadata") {
1329
+ providerMetadata = event.providerMetadata;
1330
+ } else if (event.type === "error") {
1331
+ throw new Error(event.message);
604
1332
  }
605
- return { result: null, index };
606
1333
  }
607
- })(),
1334
+ if (lastCommitMessage) {
1335
+ const { generationId, cost } = extractGatewayMetadata(providerMetadata);
1336
+ yield {
1337
+ content: lastCommitMessage,
1338
+ slotId: model,
1339
+ model,
1340
+ cost,
1341
+ generationId,
1342
+ ...useStream ? { isPartial: false } : {},
1343
+ slotIndex
1344
+ };
1345
+ }
1346
+ } catch (error) {
1347
+ if (signal?.aborted || isAbortError(error)) return;
1348
+ if (error instanceof InsufficientBalanceError) throw error;
1349
+ if (isInvalidCliSessionIdError(error)) throw error;
1350
+ }
1351
+ }
1352
+ const iterators = models.map((model, index) => ({
1353
+ iterator: generateForModel(model, index)[Symbol.asyncIterator](),
608
1354
  index
609
1355
  }));
610
- const remaining = new Map(pending.map((p) => [p.index, p.promise]));
1356
+ const pending = /* @__PURE__ */ new Map();
1357
+ const startNext = (it) => {
1358
+ const promise = it.iterator.next().then((result) => ({
1359
+ result,
1360
+ index: it.index
1361
+ }));
1362
+ pending.set(it.index, {
1363
+ iterator: it.iterator,
1364
+ promise,
1365
+ index: it.index
1366
+ });
1367
+ };
1368
+ for (const it of iterators) {
1369
+ startNext(it);
1370
+ }
1371
+ const abortPromise = createAbortPromise(signal);
611
1372
  try {
612
- while (remaining.size > 0) {
613
- const { result, index } = await Promise.race(remaining.values());
614
- remaining.delete(index);
615
- if (result) yield result;
1373
+ while (pending.size > 0) {
1374
+ if (signal?.aborted) break;
1375
+ const next = Promise.race(
1376
+ Array.from(pending.values()).map((p) => p.promise)
1377
+ );
1378
+ const winner = abortPromise ? await Promise.race([next, abortPromise]) : await next;
1379
+ if (!winner || signal?.aborted) break;
1380
+ const { result, index } = winner;
1381
+ const entry = pending.get(index);
1382
+ if (!entry) continue;
1383
+ if (result.done) {
1384
+ pending.delete(index);
1385
+ } else {
1386
+ yield result.value;
1387
+ startNext({ iterator: entry.iterator, index });
1388
+ }
616
1389
  }
617
1390
  } catch (error) {
618
1391
  if (error instanceof InsufficientBalanceError) {
@@ -625,17 +1398,23 @@ async function* generateCommitMessages(options) {
625
1398
  }
626
1399
  }
627
1400
 
628
- // src/commands/commit.ts
1401
+ // commands/commit.ts
1402
+ function showQuotaInfo(quota) {
1403
+ const { relative, local } = formatResetTime(quota.resetsAt);
1404
+ console.log("");
1405
+ console.log(
1406
+ ui.hint(
1407
+ `Daily quota: ${quota.remaining} of ${quota.limit} remaining. Resets ${relative} (${local}).`
1408
+ )
1409
+ );
1410
+ }
629
1411
  function parseArgs(args2) {
630
1412
  let models = [];
631
- let mock = false;
632
1413
  for (let i = 0; i < args2.length; i++) {
633
1414
  const arg = args2[i];
634
1415
  if (arg === "--models" && args2[i + 1]) {
635
1416
  models = args2[i + 1].split(",").map((m) => m.trim());
636
1417
  i++;
637
- } else if (arg === "--mock") {
638
- mock = true;
639
1418
  }
640
1419
  }
641
1420
  if (models.length === 0) {
@@ -643,9 +1422,7 @@ function parseArgs(args2) {
643
1422
  }
644
1423
  return {
645
1424
  message: args2.includes("-m") || args2.includes("--message"),
646
- dryRun: args2.includes("--dry-run"),
647
1425
  interactive: !args2.includes("--no-interactive"),
648
- mock,
649
1426
  models
650
1427
  };
651
1428
  }
@@ -700,23 +1477,70 @@ async function commit(args2) {
700
1477
  );
701
1478
  process.exit(1);
702
1479
  }
703
- const createGenerator = () => generateCommitMessages({
1480
+ const token = await getToken();
1481
+ if (!token) {
1482
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
1483
+ process.exit(1);
1484
+ }
1485
+ const api = createApiClient(token);
1486
+ const {
1487
+ commandExecutionPromise: promise,
1488
+ abortController,
1489
+ cliSessionId: id
1490
+ } = startCommandExecution({
1491
+ api,
1492
+ command: "commit",
1493
+ args: args2,
1494
+ apiPath: "/v1/commit-message",
1495
+ requestPayload: {
1496
+ input: diff,
1497
+ target: "vcs-commit-message",
1498
+ models: options.models
1499
+ }
1500
+ });
1501
+ const cliSessionId = id;
1502
+ const commandExecutionSignal = abortController.signal;
1503
+ const commandExecutionPromise = promise;
1504
+ const apiClient = api;
1505
+ commandExecutionPromise.catch(async (error) => {
1506
+ abortController.abort(abortReasonForError(error));
1507
+ await handleCommandExecutionError(error, {
1508
+ progress: { ready: 0, total: options.models.length }
1509
+ });
1510
+ });
1511
+ const recordSelection = async (generationId) => {
1512
+ if (!generationId || !apiClient) return;
1513
+ try {
1514
+ await apiClient.recordGenerationScore({
1515
+ generationId,
1516
+ value: 1
1517
+ });
1518
+ } catch (error) {
1519
+ const message = error instanceof Error ? error.message : String(error);
1520
+ if (message.includes("Generation not found")) {
1521
+ return;
1522
+ }
1523
+ console.error(`Warning: Failed to record selection. ${message}`);
1524
+ }
1525
+ };
1526
+ const createCandidates = (signal) => generateCommitMessages({
704
1527
  diff,
705
1528
  models: options.models,
706
- mock: options.mock
1529
+ signal: mergeAbortSignals(signal, commandExecutionSignal),
1530
+ cliSessionId,
1531
+ commandExecutionPromise
707
1532
  });
708
1533
  if (!options.interactive) {
709
1534
  const gen = generateCommitMessages({
710
1535
  diff,
711
1536
  models: options.models.slice(0, 1),
712
- mock: options.mock
1537
+ signal: commandExecutionSignal,
1538
+ cliSessionId,
1539
+ commandExecutionPromise
713
1540
  });
714
1541
  const first = await gen.next();
1542
+ await recordSelection(first.value?.generationId);
715
1543
  const message = first.value?.content ?? "";
716
- if (options.dryRun) {
717
- console.log(message);
718
- return;
719
- }
720
1544
  if (options.message) {
721
1545
  commitWithMessage(message);
722
1546
  return;
@@ -729,21 +1553,19 @@ async function commit(args2) {
729
1553
  commitWithMessage(editedMessage);
730
1554
  return;
731
1555
  }
732
- if (options.dryRun) {
733
- for await (const candidate of createGenerator()) {
734
- console.log("---");
735
- console.log(candidate.content);
736
- }
737
- return;
738
- }
739
1556
  const stats = getGitStagedStats();
740
- console.log(`\x1B[32m\u2714\x1B[0m Found ${formatDiffStats(stats)}`);
1557
+ console.log(ui.success(`Found ${formatDiffStats(stats)}`));
741
1558
  while (true) {
742
1559
  const result = await selectCandidate({
743
- candidates: createGenerator(),
744
- maxSlots: options.models.length
1560
+ createCandidates,
1561
+ maxSlots: options.models.length,
1562
+ abortSignal: commandExecutionSignal,
1563
+ models: options.models
745
1564
  });
746
1565
  if (result.action === "abort") {
1566
+ if (isCommandExecutionAbort(commandExecutionSignal)) {
1567
+ return;
1568
+ }
747
1569
  console.error("Aborting commit.");
748
1570
  process.exit(1);
749
1571
  }
@@ -751,9 +1573,11 @@ async function commit(args2) {
751
1573
  continue;
752
1574
  }
753
1575
  if (result.action === "confirm" && result.selected) {
754
- console.log(`\x1B[32m\u2714\x1B[0m Message selected`);
1576
+ await recordSelection(result.selectedCandidate?.generationId);
1577
+ const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
1578
+ console.log(ui.success(`Message selected${costLabel}`));
755
1579
  if (options.message) {
756
- console.log(`\x1B[32m\u2714\x1B[0m Running git commit
1580
+ console.log(`${ui.success("Running git commit")}
757
1581
  `);
758
1582
  commitWithMessage(result.selected);
759
1583
  } else {
@@ -762,16 +1586,19 @@ async function commit(args2) {
762
1586
  console.error("Aborting commit due to empty message.");
763
1587
  process.exit(1);
764
1588
  }
765
- console.log(`\x1B[32m\u2714\x1B[0m Running git commit
1589
+ console.log(`${ui.success("Running git commit")}
766
1590
  `);
767
1591
  commitWithMessage(editedMessage);
768
1592
  }
1593
+ if (result.quota) {
1594
+ showQuotaInfo(result.quota);
1595
+ }
769
1596
  return;
770
1597
  }
771
1598
  }
772
1599
  }
773
1600
 
774
- // src/git-ultrahope.ts
1601
+ // git-ultrahope.ts
775
1602
  var [command, ...args] = process.argv.slice(2);
776
1603
  async function main() {
777
1604
  switch (command) {
@@ -793,18 +1620,16 @@ function printHelp() {
793
1620
  console.log(`Usage: git ultrahope <command>
794
1621
 
795
1622
  Commands:
796
- commit Generate commit message from staged changes
1623
+ commit Generate commit message from staged changes
797
1624
 
798
1625
  Commit options:
799
- -m, --message Commit directly with generated message
800
- --dry-run Print candidates only, don't commit
801
- --no-interactive Single candidate, open in editor
1626
+ -m, --message Commit directly with generated message
1627
+ --no-interactive Single candidate, open in editor
802
1628
 
803
1629
  Examples:
804
- git ultrahope commit # interactive selector (default)
805
- git ultrahope commit -m # select and commit directly
806
- git ultrahope commit --dry-run # preview candidates only
807
- git ultrahope commit --no-interactive # single candidate, open editor`);
1630
+ git ultrahope commit # interactive selector (default)
1631
+ git ultrahope commit -m # select and commit directly
1632
+ git ultrahope commit --no-interactive # single candidate, open editor`);
808
1633
  }
809
1634
  main().catch((err) => {
810
1635
  console.error(err);