@ridit/lens 0.2.2 → 0.2.5

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.
@@ -0,0 +1,713 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { execSync } from "child_process";
5
+ import { existsSync } from "fs";
6
+ import path from "path";
7
+ import figures from "figures";
8
+ import { ACCENT } from "../colors";
9
+ import { ProviderPicker } from "../components/repo/ProviderPicker";
10
+ import { callChat } from "../utils/chat";
11
+ import type { Provider } from "../types/config";
12
+ import type { Message } from "../types/chat";
13
+
14
+ function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
15
+ try {
16
+ const out = execSync(cmd, {
17
+ cwd,
18
+ encoding: "utf-8",
19
+ stdio: ["pipe", "pipe", "pipe"],
20
+ timeout: 30_000,
21
+ }).trim();
22
+ return { ok: true, out };
23
+ } catch (e: any) {
24
+ const msg =
25
+ [e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
26
+ return { ok: false, out: msg };
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Commit with a multi-line message correctly.
32
+ * Uses execFileSync with an args array — no shell, no quoting issues.
33
+ * Splits the message into paragraphs and passes each as a separate -m flag
34
+ * so git formats the commit body properly (blank line between subject and body).
35
+ */
36
+ function gitCommit(message: string, cwd: string): { ok: boolean; out: string } {
37
+ const { execFileSync } =
38
+ require("child_process") as typeof import("child_process");
39
+
40
+ const paragraphs = message
41
+ .split(/\n\n+/)
42
+ .map((p) => p.trim())
43
+ .filter(Boolean);
44
+
45
+ const mArgs: string[] = [];
46
+ for (const p of paragraphs) {
47
+ mArgs.push("-m", p);
48
+ }
49
+
50
+ try {
51
+ const out = execFileSync("git", ["commit", ...mArgs], {
52
+ cwd,
53
+ encoding: "utf-8",
54
+ stdio: ["pipe", "pipe", "pipe"],
55
+ timeout: 30_000,
56
+ }).trim();
57
+ return { ok: true, out };
58
+ } catch (e: any) {
59
+ const msg =
60
+ [e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
61
+ return { ok: false, out: msg };
62
+ }
63
+ }
64
+
65
+ /** Stage specific files or everything (-A) */
66
+ function stageFiles(
67
+ files: string[],
68
+ cwd: string,
69
+ ): { ok: boolean; out: string } {
70
+ if (files.length === 0) return gitRun("git add -A", cwd);
71
+
72
+ const paths = files.map((f) => `"${f}"`).join(" ");
73
+ return gitRun(`git add -- ${paths}`, cwd);
74
+ }
75
+
76
+ function getStagedDiff(cwd: string): string {
77
+ return gitRun("git diff --staged", cwd).out;
78
+ }
79
+
80
+ /** Diff only the specified files (unstaged) */
81
+ function getFileDiff(files: string[], cwd: string): string {
82
+ if (files.length === 0) {
83
+ const tracked = gitRun("git diff HEAD", cwd).out;
84
+ const untracked = gitRun("git ls-files --others --exclude-standard", cwd)
85
+ .out.split("\n")
86
+ .filter(Boolean)
87
+ .slice(0, 10)
88
+ .map((f) => `=== new file: ${f} ===`)
89
+ .join("\n");
90
+ return [tracked, untracked].filter(Boolean).join("\n\n");
91
+ }
92
+ const paths = files.map((f) => `"${f}"`).join(" ");
93
+ return gitRun(`git diff HEAD -- ${paths}`, cwd).out;
94
+ }
95
+
96
+ function hasStagedChanges(cwd: string): boolean {
97
+ return !gitRun("git diff --staged --quiet", cwd).ok;
98
+ }
99
+
100
+ function hasAnyChanges(cwd: string): boolean {
101
+ return gitRun("git status --porcelain", cwd).out.trim().length > 0;
102
+ }
103
+
104
+ /** Validate that all specified files exist on disk */
105
+ function validateFiles(
106
+ files: string[],
107
+ cwd: string,
108
+ ): { missing: string[]; valid: string[] } {
109
+ const missing: string[] = [];
110
+ const valid: string[] = [];
111
+ for (const f of files) {
112
+ const abs = path.isAbsolute(f) ? f : path.join(cwd, f);
113
+ if (existsSync(abs)) {
114
+ valid.push(path.relative(cwd, abs).replace(/\\/g, "/"));
115
+ } else {
116
+ missing.push(f);
117
+ }
118
+ }
119
+ return { missing, valid };
120
+ }
121
+
122
+ function detectSplitOpportunity(diff: string): string[] {
123
+ const fileMatches = [...diff.matchAll(/^diff --git a\/.+ b\/(.+)$/gm)];
124
+ const files = fileMatches.map((m) => m[1]!);
125
+ if (files.length <= 3) return [];
126
+
127
+ const groups = new Map<string, string[]>();
128
+ for (const f of files) {
129
+ const parts = f.split("/");
130
+ const group =
131
+ parts[0] === "src" && parts.length > 1 ? parts[1]! : parts[0]!;
132
+ if (!groups.has(group)) groups.set(group, []);
133
+ groups.get(group)!.push(f);
134
+ }
135
+
136
+ const meaningful = [...groups.entries()].filter(([, fs]) => fs.length >= 2);
137
+ return meaningful.length >= 2
138
+ ? meaningful.map(([g, fs]) => `${g}/ (${fs.length} files)`)
139
+ : [];
140
+ }
141
+
142
+ const SYSTEM_PROMPT = `You are an expert at writing conventional commit messages.
143
+ Given a git diff, analyze the changes and write a single commit message.
144
+
145
+ Rules:
146
+ - Use conventional commits format: type(scope): description
147
+ - Types: feat, fix, refactor, perf, docs, style, test, chore, ci, build
148
+ - First line: max 72 chars, imperative mood (add, fix, update — not added/fixed)
149
+ - After the first line, add a blank line then bullet points for each logical change
150
+ - Bullet format: "- <what changed and why>"
151
+ - Group related changes into 2–5 bullets max
152
+ - Be specific — mention file names, feature names, component names
153
+ - No markdown, no backticks, no code blocks
154
+ - Output ONLY the commit message, nothing else
155
+
156
+ Example output:
157
+ feat(editor): add syntax highlighting for TypeScript
158
+
159
+ - add Monaco tokenizer for .ts and .tsx files
160
+ - configure theme tokens to match dark mode palette
161
+ - expose highlight API for external extensions`;
162
+
163
+ async function generateCommitMessage(
164
+ provider: Provider,
165
+ diff: string,
166
+ ): Promise<string> {
167
+ const msgs: Message[] = [
168
+ {
169
+ role: "user",
170
+ content: `Write a conventional commit message for this diff:\n\n${diff.slice(0, 8000)}`,
171
+ type: "text",
172
+ },
173
+ ];
174
+ const raw = await callChat(provider, SYSTEM_PROMPT, msgs);
175
+ return typeof raw === "string" ? raw.trim() : "chore: update files";
176
+ }
177
+
178
+ function trunc(s: string, n: number) {
179
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
180
+ }
181
+
182
+ const PHRASES = [
183
+ "reading your crimes…",
184
+ "sniffing the diff…",
185
+ "crafting the perfect message…",
186
+ "turning chaos into conventional commits…",
187
+ "pretending this was intentional…",
188
+ "72 chars or bust…",
189
+ "making main proud…",
190
+ "git blame: not it…",
191
+ "this commit brought to you by AI…",
192
+ ];
193
+
194
+ function randomPhrase() {
195
+ return PHRASES[Math.floor(Math.random() * PHRASES.length)]!;
196
+ }
197
+
198
+ type Phase =
199
+ | { type: "checking" }
200
+ | { type: "no-changes" }
201
+ | { type: "no-staged"; hasUnstaged: boolean; files: string[] }
202
+ | { type: "staging"; files: string[] }
203
+ | { type: "generating" }
204
+ | { type: "preview"; message: string; splitGroups: string[]; diff: string }
205
+ | { type: "editing"; message: string; diff: string }
206
+ | { type: "committing"; message: string }
207
+ | { type: "pushing"; message: string; hash: string }
208
+ | { type: "done"; message: string; hash: string; pushed: boolean }
209
+ | { type: "preview-only"; message: string }
210
+ | { type: "error"; message: string };
211
+
212
+ function CommitRunner({
213
+ cwd,
214
+ provider,
215
+ files,
216
+ auto,
217
+ preview,
218
+ push,
219
+ confirm,
220
+ }: {
221
+ cwd: string;
222
+ provider: Provider;
223
+ /** Specific files to stage. Empty = all (-A when --auto, or use existing staged) */
224
+ files: string[];
225
+ auto: boolean;
226
+ preview: boolean;
227
+ push: boolean;
228
+ /** Show preview even with --auto before committing */
229
+ confirm: boolean;
230
+ }) {
231
+ const [phase, setPhase] = useState<Phase>({ type: "checking" });
232
+ const [phraseText, setPhraseText] = useState(randomPhrase());
233
+
234
+ useEffect(() => {
235
+ if (phase.type !== "generating") return;
236
+ const id = setInterval(() => setPhraseText(randomPhrase()), 2800);
237
+ return () => clearInterval(id);
238
+ }, [phase.type]);
239
+
240
+ useEffect(() => {
241
+ (async () => {
242
+ if (!gitRun("git rev-parse --git-dir", cwd).ok) {
243
+ setPhase({ type: "error", message: "not a git repository" });
244
+ return;
245
+ }
246
+
247
+ if (files.length > 0) {
248
+ const { missing, valid } = validateFiles(files, cwd);
249
+ if (missing.length > 0) {
250
+ setPhase({
251
+ type: "error",
252
+ message: `file${missing.length > 1 ? "s" : ""} not found:\n${missing.map((f) => ` ${f}`).join("\n")}`,
253
+ });
254
+ return;
255
+ }
256
+
257
+ setPhase({ type: "staging", files: valid });
258
+ const r = stageFiles(valid, cwd);
259
+ if (!r.ok) {
260
+ setPhase({ type: "error", message: `staging failed: ${r.out}` });
261
+ return;
262
+ }
263
+ } else if (auto) {
264
+ if (!hasAnyChanges(cwd)) {
265
+ setPhase({ type: "no-changes" });
266
+ return;
267
+ }
268
+ setPhase({ type: "staging", files: [] });
269
+ gitRun("git add -A", cwd);
270
+ }
271
+
272
+ if (!hasStagedChanges(cwd)) {
273
+ const unstaged = hasAnyChanges(cwd);
274
+ setPhase({ type: "no-staged", hasUnstaged: unstaged, files });
275
+ return;
276
+ }
277
+
278
+ const diff = getStagedDiff(cwd) || getFileDiff(files, cwd);
279
+ if (!diff.trim()) {
280
+ setPhase({ type: "no-changes" });
281
+ return;
282
+ }
283
+
284
+ setPhase({ type: "generating" });
285
+
286
+ const commitAndMaybePush = (message: string) => {
287
+ setPhase({ type: "committing", message });
288
+ const r = gitCommit(message, cwd);
289
+ if (!r.ok) {
290
+ setPhase({ type: "error", message: r.out });
291
+ return false;
292
+ }
293
+ const hash = gitRun("git rev-parse --short HEAD", cwd).out || "?";
294
+ if (push) {
295
+ setPhase({ type: "pushing", message, hash });
296
+ const pr = gitRun("git push", cwd);
297
+ if (!pr.ok) {
298
+ setPhase({ type: "error", message: `push failed: ${pr.out}` });
299
+ return false;
300
+ }
301
+ }
302
+ setPhase({ type: "done", message, hash, pushed: push });
303
+ return true;
304
+ };
305
+
306
+ try {
307
+ const message = await generateCommitMessage(provider, diff);
308
+ const splitGroups = detectSplitOpportunity(diff);
309
+
310
+ if (preview) {
311
+ setPhase({ type: "preview-only", message });
312
+ return;
313
+ }
314
+
315
+ if (auto && files.length === 0 && !confirm) {
316
+ commitAndMaybePush(message);
317
+ return;
318
+ }
319
+
320
+ setPhase({ type: "preview", message, splitGroups, diff });
321
+ } catch (e: any) {
322
+ setPhase({
323
+ type: "error",
324
+ message: `AI error: ${e.message ?? String(e)}`,
325
+ });
326
+ }
327
+ })();
328
+ }, []);
329
+
330
+ useInput((inp, key) => {
331
+ if (phase.type === "preview") {
332
+ if (inp === "y" || inp === "Y" || key.return) {
333
+ const message = phase.message;
334
+ setPhase({ type: "committing", message });
335
+ const r = gitCommit(message, cwd);
336
+ if (!r.ok) {
337
+ setPhase({ type: "error", message: r.out });
338
+ return;
339
+ }
340
+ const hash = gitRun("git rev-parse --short HEAD", cwd).out || "?";
341
+ if (push) {
342
+ setPhase({ type: "pushing", message, hash });
343
+ const pr = gitRun("git push", cwd);
344
+ if (!pr.ok) {
345
+ setPhase({ type: "error", message: `push failed: ${pr.out}` });
346
+ return;
347
+ }
348
+ }
349
+ setPhase({ type: "done", message, hash, pushed: push });
350
+ return;
351
+ }
352
+ if (inp === "e" || inp === "E") {
353
+ setPhase({ type: "editing", message: phase.message, diff: phase.diff });
354
+ return;
355
+ }
356
+ if (inp === "n" || inp === "N" || key.escape) {
357
+ process.exit(0);
358
+ }
359
+ }
360
+
361
+ if (phase.type === "editing" && key.escape) {
362
+ setPhase((prev) =>
363
+ prev.type === "editing"
364
+ ? {
365
+ type: "preview",
366
+ message: prev.message,
367
+ splitGroups: [],
368
+ diff: prev.diff,
369
+ }
370
+ : prev,
371
+ );
372
+ }
373
+
374
+ if (
375
+ (phase.type === "done" ||
376
+ phase.type === "no-changes" ||
377
+ phase.type === "no-staged" ||
378
+ phase.type === "preview-only" ||
379
+ phase.type === "error") &&
380
+ (key.return || key.escape || inp === "q")
381
+ ) {
382
+ process.exit(0);
383
+ }
384
+ });
385
+
386
+ const w = process.stdout.columns ?? 80;
387
+ const div = "─".repeat(w);
388
+
389
+ return (
390
+ <Box flexDirection="column" paddingY={1}>
391
+ <Box gap={2} marginBottom={1}>
392
+ <Text color={ACCENT} bold>
393
+ ◈ COMMIT
394
+ </Text>
395
+ <Text color="gray" dimColor>
396
+ {cwd}
397
+ </Text>
398
+ {files.length > 0 && (
399
+ <Text color="cyan" dimColor>
400
+ {files.length} file{files.length !== 1 ? "s" : ""}
401
+ </Text>
402
+ )}
403
+ </Box>
404
+
405
+ {files.length > 0 && (
406
+ <Box flexDirection="column" marginBottom={1}>
407
+ {files.map((f, i) => (
408
+ <Box key={i} gap={1}>
409
+ <Text color="gray" dimColor>
410
+ {" ·"}
411
+ </Text>
412
+ <Text color="white">{f}</Text>
413
+ </Box>
414
+ ))}
415
+ </Box>
416
+ )}
417
+
418
+ <Text color="gray" dimColor>
419
+ {div}
420
+ </Text>
421
+
422
+ {phase.type === "checking" && (
423
+ <Box gap={1} marginTop={1}>
424
+ <Text color={ACCENT}>*</Text>
425
+ <Text color="gray" dimColor>
426
+ checking changes…
427
+ </Text>
428
+ </Box>
429
+ )}
430
+
431
+ {phase.type === "staging" && (
432
+ <Box gap={1} marginTop={1}>
433
+ <Text color={ACCENT}>*</Text>
434
+ <Text color="gray" dimColor>
435
+ {phase.files.length > 0
436
+ ? `staging ${phase.files.length} file${phase.files.length !== 1 ? "s" : ""}…`
437
+ : "staging all changes…"}
438
+ </Text>
439
+ </Box>
440
+ )}
441
+
442
+ {phase.type === "no-changes" && (
443
+ <Box flexDirection="column" marginTop={1} gap={1}>
444
+ <Box gap={1}>
445
+ <Text color="yellow">{figures.warning}</Text>
446
+ <Text color="white">nothing to commit — working tree is clean</Text>
447
+ </Box>
448
+ </Box>
449
+ )}
450
+
451
+ {phase.type === "no-staged" && (
452
+ <Box flexDirection="column" marginTop={1} gap={1}>
453
+ <Box gap={1}>
454
+ <Text color="yellow">{figures.warning}</Text>
455
+ <Text color="white">no staged changes found</Text>
456
+ </Box>
457
+ {phase.hasUnstaged && (
458
+ <Box flexDirection="column" marginLeft={2} gap={1}>
459
+ <Text color="gray" dimColor>
460
+ you have unstaged changes. try:
461
+ </Text>
462
+ {phase.files.length > 0 ? (
463
+ <Text color="gray" dimColor>
464
+ {" "}
465
+ <Text color={ACCENT}>
466
+ lens commit {phase.files.join(" ")}
467
+ </Text>
468
+ {" "}(stages and commits those files)
469
+ </Text>
470
+ ) : (
471
+ <Text color="gray" dimColor>
472
+ {" "}
473
+ <Text color={ACCENT}>git add {"<files>"}</Text>
474
+ {" "}or{" "}
475
+ <Text color={ACCENT}>lens commit --auto</Text>
476
+ </Text>
477
+ )}
478
+ </Box>
479
+ )}
480
+ </Box>
481
+ )}
482
+
483
+ {phase.type === "generating" && (
484
+ <Box gap={1} marginTop={1}>
485
+ <Text color={ACCENT}>●</Text>
486
+ <Text color="gray" dimColor>
487
+ {phraseText}
488
+ </Text>
489
+ </Box>
490
+ )}
491
+
492
+ {phase.type === "preview" && (
493
+ <Box flexDirection="column" marginTop={1} gap={1}>
494
+ <Text color={ACCENT} bold>
495
+ GENERATED MESSAGE
496
+ </Text>
497
+ <Box
498
+ flexDirection="column"
499
+ marginLeft={2}
500
+ marginTop={1}
501
+ marginBottom={1}
502
+ >
503
+ {phase.message.split("\n").map((line, i) => (
504
+ <Text key={i} color={i === 0 ? "white" : "gray"} bold={i === 0}>
505
+ {line || " "}
506
+ </Text>
507
+ ))}
508
+ </Box>
509
+
510
+ {phase.splitGroups.length > 0 && (
511
+ <Box flexDirection="column" marginLeft={2} marginBottom={1}>
512
+ <Text color="yellow" dimColor>
513
+ ⚡ large diff — consider splitting into{" "}
514
+ {phase.splitGroups.length} commits:
515
+ </Text>
516
+ {phase.splitGroups.map((g, i) => (
517
+ <Text key={i} color="gray" dimColor>
518
+ {" · "}
519
+ {g}
520
+ </Text>
521
+ ))}
522
+ </Box>
523
+ )}
524
+
525
+ <Text color="gray" dimColor>
526
+ {div}
527
+ </Text>
528
+ <Box gap={3} marginTop={1}>
529
+ <Text color="green">y/enter commit</Text>
530
+ <Text color="cyan">e edit</Text>
531
+ <Text color="gray" dimColor>
532
+ n/esc cancel
533
+ </Text>
534
+ </Box>
535
+ </Box>
536
+ )}
537
+
538
+ {phase.type === "editing" && (
539
+ <Box flexDirection="column" marginTop={1} gap={1}>
540
+ <Text color={ACCENT} bold>
541
+ EDIT MESSAGE
542
+ </Text>
543
+ <Box marginLeft={2} marginTop={1} flexDirection="column" gap={1}>
544
+ <TextInput
545
+ value={phase.message}
546
+ onChange={(msg) =>
547
+ setPhase((prev) =>
548
+ prev.type === "editing" ? { ...prev, message: msg } : prev,
549
+ )
550
+ }
551
+ onSubmit={(msg) =>
552
+ setPhase((prev) =>
553
+ prev.type === "editing"
554
+ ? {
555
+ type: "preview",
556
+ message: msg,
557
+ splitGroups: [],
558
+ diff: prev.diff,
559
+ }
560
+ : prev,
561
+ )
562
+ }
563
+ />
564
+ <Text color="gray" dimColor>
565
+ enter confirm · esc back
566
+ </Text>
567
+ </Box>
568
+ </Box>
569
+ )}
570
+
571
+ {phase.type === "committing" && (
572
+ <Box gap={1} marginTop={1}>
573
+ <Text color={ACCENT}>*</Text>
574
+ <Text color="gray" dimColor>
575
+ committing…
576
+ </Text>
577
+ </Box>
578
+ )}
579
+
580
+ {phase.type === "pushing" && (
581
+ <Box flexDirection="column" marginTop={1} gap={1}>
582
+ <Box gap={2}>
583
+ <Text color="green">{figures.tick}</Text>
584
+ <Text color={ACCENT}>{phase.hash}</Text>
585
+ <Text color="white">
586
+ {trunc(phase.message.split("\n")[0]!, 65)}
587
+ </Text>
588
+ </Box>
589
+ <Box gap={1} marginLeft={2}>
590
+ <Text color={ACCENT}>*</Text>
591
+ <Text color="gray" dimColor>
592
+ pushing…
593
+ </Text>
594
+ </Box>
595
+ </Box>
596
+ )}
597
+
598
+ {phase.type === "done" && (
599
+ <Box flexDirection="column" marginTop={1} gap={1}>
600
+ <Box gap={2}>
601
+ <Text color="green">{figures.tick}</Text>
602
+ <Text color={ACCENT}>{phase.hash}</Text>
603
+ <Text color="white" bold>
604
+ {trunc(phase.message.split("\n")[0]!, 65)}
605
+ </Text>
606
+ </Box>
607
+ {phase.message
608
+ .split("\n")
609
+ .slice(2)
610
+ .filter(Boolean)
611
+ .map((line, i) => (
612
+ <Text key={i} color="gray" dimColor>
613
+ {line}
614
+ </Text>
615
+ ))}
616
+ {phase.pushed && (
617
+ <Box gap={2} marginTop={1}>
618
+ <Text color="green">{figures.tick}</Text>
619
+ <Text color="gray" dimColor>
620
+ pushed to remote
621
+ </Text>
622
+ </Box>
623
+ )}
624
+ <Text color="gray" dimColor>
625
+ press any key to exit
626
+ </Text>
627
+ </Box>
628
+ )}
629
+
630
+ {phase.type === "preview-only" && (
631
+ <Box flexDirection="column" marginTop={1} gap={1}>
632
+ <Text color={ACCENT} bold>
633
+ GENERATED MESSAGE
634
+ </Text>
635
+ <Box flexDirection="column" marginLeft={2} marginTop={1}>
636
+ {phase.message.split("\n").map((line, i) => (
637
+ <Text key={i} color={i === 0 ? "white" : "gray"} bold={i === 0}>
638
+ {line || " "}
639
+ </Text>
640
+ ))}
641
+ </Box>
642
+ <Text color="gray" dimColor>
643
+ (preview only — not committed)
644
+ </Text>
645
+ </Box>
646
+ )}
647
+
648
+ {phase.type === "error" && (
649
+ <Box flexDirection="column" marginTop={1} gap={1}>
650
+ <Box gap={1}>
651
+ <Text color="red">{figures.cross}</Text>
652
+ <Text color="white">{phase.message.split("\n")[0]}</Text>
653
+ </Box>
654
+ {phase.message
655
+ .split("\n")
656
+ .slice(1)
657
+ .map((line, i) => (
658
+ <Text key={i} color="gray" dimColor>
659
+ {line}
660
+ </Text>
661
+ ))}
662
+ </Box>
663
+ )}
664
+ </Box>
665
+ );
666
+ }
667
+
668
+ interface Props {
669
+ path: string;
670
+ files: string[];
671
+ auto: boolean;
672
+ preview: boolean;
673
+ push: boolean;
674
+ confirm: boolean;
675
+ }
676
+
677
+ export function CommitCommand({
678
+ path: inputPath,
679
+ files,
680
+ auto,
681
+ preview,
682
+ push,
683
+ confirm,
684
+ }: Props) {
685
+ const cwd = path.resolve(inputPath);
686
+ const [provider, setProvider] = useState<Provider | null>(null);
687
+
688
+ if (!existsSync(cwd)) {
689
+ return (
690
+ <Box marginTop={1}>
691
+ <Text color="red">
692
+ {figures.cross} path not found: {cwd}
693
+ </Text>
694
+ </Box>
695
+ );
696
+ }
697
+
698
+ if (!provider) {
699
+ return <ProviderPicker onDone={setProvider} />;
700
+ }
701
+
702
+ return (
703
+ <CommitRunner
704
+ cwd={cwd}
705
+ provider={provider}
706
+ files={files}
707
+ auto={auto}
708
+ preview={preview}
709
+ push={push}
710
+ confirm={confirm}
711
+ />
712
+ );
713
+ }