@ridit/lens 0.2.4 → 0.2.6

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