@avantmedia/af 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +539 -0
  3. package/af +2 -0
  4. package/bun-upgrade.ts +130 -0
  5. package/commands/bun.ts +55 -0
  6. package/commands/changes.ts +35 -0
  7. package/commands/e2e.ts +12 -0
  8. package/commands/help.ts +236 -0
  9. package/commands/install-extension.ts +133 -0
  10. package/commands/jira.ts +577 -0
  11. package/commands/licenses.ts +32 -0
  12. package/commands/npm.ts +55 -0
  13. package/commands/scaffold.ts +105 -0
  14. package/commands/setup.tsx +156 -0
  15. package/commands/spec.ts +405 -0
  16. package/commands/stop-hook.ts +90 -0
  17. package/commands/todo.ts +208 -0
  18. package/commands/versions.ts +150 -0
  19. package/commands/watch.ts +344 -0
  20. package/commands/worktree.ts +424 -0
  21. package/components/change-select.tsx +71 -0
  22. package/components/confirm.tsx +41 -0
  23. package/components/file-conflict.tsx +52 -0
  24. package/components/input.tsx +53 -0
  25. package/components/layout.tsx +70 -0
  26. package/components/messages.tsx +48 -0
  27. package/components/progress.tsx +71 -0
  28. package/components/select.tsx +90 -0
  29. package/components/status-display.tsx +74 -0
  30. package/components/table.tsx +79 -0
  31. package/generated/setup-manifest.ts +67 -0
  32. package/git-worktree.ts +184 -0
  33. package/main.ts +12 -0
  34. package/npm-upgrade.ts +117 -0
  35. package/package.json +83 -0
  36. package/resources/copy-prompt-reporter.ts +443 -0
  37. package/router.ts +220 -0
  38. package/setup/.claude/commands/commit-work.md +47 -0
  39. package/setup/.claude/commands/complete-work.md +34 -0
  40. package/setup/.claude/commands/e2e.md +29 -0
  41. package/setup/.claude/commands/start-work.md +51 -0
  42. package/setup/.claude/skills/pm/SKILL.md +294 -0
  43. package/setup/.claude/skills/pm/templates/api-endpoint.md +69 -0
  44. package/setup/.claude/skills/pm/templates/bug-fix.md +77 -0
  45. package/setup/.claude/skills/pm/templates/feature.md +87 -0
  46. package/setup/.claude/skills/pm/templates/ui-component.md +78 -0
  47. package/utils/change-select-render.tsx +44 -0
  48. package/utils/claude.ts +9 -0
  49. package/utils/config.ts +58 -0
  50. package/utils/env.ts +53 -0
  51. package/utils/git.ts +120 -0
  52. package/utils/ink-render.tsx +50 -0
  53. package/utils/openspec.ts +54 -0
  54. package/utils/output.ts +104 -0
  55. package/utils/proposal.ts +160 -0
  56. package/utils/resources.ts +64 -0
  57. package/utils/setup-files.ts +230 -0
@@ -0,0 +1,424 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import {
5
+ hasUncommittedChanges,
6
+ listWorktrees,
7
+ resetWorktree,
8
+ type Worktree,
9
+ } from '../git-worktree.ts';
10
+ import { error, info, success } from '../utils/output.ts';
11
+
12
+ /**
13
+ * Check if we're in a git repository.
14
+ *
15
+ * @returns true if in a git repository, false otherwise
16
+ */
17
+ export function isGitRepository(): boolean {
18
+ try {
19
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get the root directory of the current git repository.
28
+ *
29
+ * @returns The absolute path to the git root, or null if not in a repo
30
+ */
31
+ export function getGitRoot(): string | null {
32
+ try {
33
+ return execSync('git rev-parse --show-toplevel', { stdio: 'pipe' }).toString().trim();
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check if a worktree already exists at the given path.
41
+ *
42
+ * @param path - The path to check
43
+ * @returns true if a directory exists at the path
44
+ */
45
+ export function worktreeExists(path: string): boolean {
46
+ return existsSync(path);
47
+ }
48
+
49
+ /**
50
+ * Parse .git/info/exclude file and return patterns.
51
+ * Filters out comments and empty lines.
52
+ *
53
+ * @param gitRoot - The root of the git repository
54
+ * @returns Array of exclude patterns
55
+ */
56
+ export function parseGitInfoExclude(gitRoot: string): string[] {
57
+ const excludePath = join(gitRoot, '.git', 'info', 'exclude');
58
+
59
+ if (!existsSync(excludePath)) {
60
+ return [];
61
+ }
62
+
63
+ try {
64
+ const content = readFileSync(excludePath, 'utf-8');
65
+ return content
66
+ .split('\n')
67
+ .map(line => line.trim())
68
+ .filter(line => line.length > 0 && !line.startsWith('#'));
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Find files matching patterns in .git/info/exclude using git ls-files.
76
+ * Uses git's native pattern matching to avoid reimplementing gitignore logic.
77
+ *
78
+ * @param gitRoot - The root of the git repository
79
+ * @returns Array of file paths that are excluded
80
+ */
81
+ export function findExcludedFiles(gitRoot: string): string[] {
82
+ const excludePath = join(gitRoot, '.git', 'info', 'exclude');
83
+
84
+ if (!existsSync(excludePath)) {
85
+ return [];
86
+ }
87
+
88
+ try {
89
+ // Use git ls-files to find files matching exclude patterns
90
+ const result = execSync(`git ls-files --others --ignored --exclude-from="${excludePath}"`, {
91
+ cwd: gitRoot,
92
+ stdio: 'pipe',
93
+ });
94
+ const files = result.toString().trim().split('\n').filter(Boolean);
95
+ return files;
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get the list of files to copy to the new worktree.
103
+ * Includes .env, .env.local, and files matching .git/info/exclude patterns.
104
+ *
105
+ * @param gitRoot - The root of the git repository
106
+ * @returns Array of file paths to copy
107
+ */
108
+ export function getFilesToCopy(gitRoot: string): string[] {
109
+ const files: string[] = [];
110
+
111
+ // Add .env files if they exist
112
+ const envFiles = ['.env', '.env.local'];
113
+ for (const envFile of envFiles) {
114
+ const envPath = join(gitRoot, envFile);
115
+ if (existsSync(envPath)) {
116
+ files.push(envFile);
117
+ }
118
+ }
119
+
120
+ // Add files from .git/info/exclude
121
+ const excludedFiles = findExcludedFiles(gitRoot);
122
+ files.push(...excludedFiles);
123
+
124
+ // Remove duplicates
125
+ return [...new Set(files)];
126
+ }
127
+
128
+ /**
129
+ * Copy a file to the worktree, creating parent directories if needed.
130
+ *
131
+ * @param sourceRoot - The source git root directory
132
+ * @param targetRoot - The target worktree directory
133
+ * @param relativePath - The relative path of the file to copy
134
+ * @returns true if copy succeeded, false otherwise
135
+ */
136
+ export function copyFileToWorktree(
137
+ sourceRoot: string,
138
+ targetRoot: string,
139
+ relativePath: string,
140
+ ): boolean {
141
+ const sourcePath = join(sourceRoot, relativePath);
142
+ const targetPath = join(targetRoot, relativePath);
143
+
144
+ try {
145
+ // Create parent directories if needed
146
+ const targetDir = dirname(targetPath);
147
+ if (!existsSync(targetDir)) {
148
+ mkdirSync(targetDir, { recursive: true });
149
+ }
150
+
151
+ copyFileSync(sourcePath, targetPath);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Create a git worktree at the specified path.
160
+ *
161
+ * @param name - The name of the worktree (used for branch name and directory)
162
+ * @param detach - If true, create with detached HEAD instead of new branch
163
+ * @returns Object with success status and optional error message
164
+ */
165
+ export function createWorktree(
166
+ name: string,
167
+ detach: boolean = false,
168
+ ): { success: boolean; error?: string; path?: string } {
169
+ const gitRoot = getGitRoot();
170
+ if (!gitRoot) {
171
+ return { success: false, error: 'Not in a git repository' };
172
+ }
173
+
174
+ // Calculate target path as sibling directory
175
+ const parentDir = dirname(gitRoot);
176
+ const targetPath = resolve(parentDir, name);
177
+
178
+ // Check if target already exists
179
+ if (worktreeExists(targetPath)) {
180
+ return { success: false, error: `Directory already exists: ${targetPath}` };
181
+ }
182
+
183
+ // Build git worktree add command
184
+ const args = detach
185
+ ? ['worktree', 'add', '--detach', targetPath]
186
+ : ['worktree', 'add', '-b', name, targetPath];
187
+
188
+ const result = spawnSync('git', args, { cwd: gitRoot, stdio: 'pipe' });
189
+
190
+ if (result.status !== 0) {
191
+ const stderr = result.stderr?.toString().trim() || 'Unknown error';
192
+ return { success: false, error: stderr };
193
+ }
194
+
195
+ return { success: true, path: targetPath };
196
+ }
197
+
198
+ /**
199
+ * Handle the 'worktree new <name>' command.
200
+ * Creates a new git worktree as a sibling directory and copies env files.
201
+ *
202
+ * @param name - The name for the worktree (also used as branch name)
203
+ * @param detach - If true, create with detached HEAD instead of new branch
204
+ * @returns Exit code (0 for success, 1 for error)
205
+ */
206
+ export function handleWorktreeNew(name: string | undefined, detach: boolean = false): number {
207
+ // Validate name is provided
208
+ if (!name || name.trim() === '') {
209
+ error('Error: worktree new requires a name');
210
+ console.error('Usage: af worktree new <name> [--detach]');
211
+ return 1;
212
+ }
213
+
214
+ // Check if in a git repository
215
+ if (!isGitRepository()) {
216
+ error('Error: Not in a git repository');
217
+ return 1;
218
+ }
219
+
220
+ const gitRoot = getGitRoot();
221
+ if (!gitRoot) {
222
+ error('Error: Could not determine git root');
223
+ return 1;
224
+ }
225
+
226
+ // Create the worktree
227
+ info(`Creating worktree '${name}'${detach ? ' (detached)' : ' with new branch'}...`);
228
+ const result = createWorktree(name, detach);
229
+
230
+ if (!result.success) {
231
+ error(`Error: ${result.error}`);
232
+ return 1;
233
+ }
234
+
235
+ const targetPath = result.path!;
236
+ success(`Worktree created at ${targetPath}`);
237
+
238
+ // Copy files
239
+ const filesToCopy = getFilesToCopy(gitRoot);
240
+ if (filesToCopy.length > 0) {
241
+ info(`Copying ${filesToCopy.length} file(s)...`);
242
+ let copied = 0;
243
+ for (const file of filesToCopy) {
244
+ if (copyFileToWorktree(gitRoot, targetPath, file)) {
245
+ copied++;
246
+ }
247
+ }
248
+ success(`Copied ${copied} file(s)`);
249
+ }
250
+
251
+ return 0;
252
+ }
253
+
254
+ /**
255
+ * Get the current worktree information if running from within a worktree.
256
+ *
257
+ * Compares the current working directory against the list of worktrees
258
+ * to determine if we're in a worktree (excluding the main repository).
259
+ *
260
+ * @returns The current worktree if in one, or null if in the main repository
261
+ */
262
+ export function getCurrentWorktree(): Worktree | null {
263
+ const currentPath = getGitRoot();
264
+ if (!currentPath) {
265
+ return null;
266
+ }
267
+
268
+ const worktrees = listWorktrees();
269
+
270
+ // The first worktree in the list is typically the main repository
271
+ // We need to find if current path matches any non-main worktree
272
+ for (let i = 1; i < worktrees.length; i++) {
273
+ if (worktrees[i].path === currentPath) {
274
+ return worktrees[i];
275
+ }
276
+ }
277
+
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * Find a worktree by its branch name.
283
+ *
284
+ * @param name - The branch name to search for
285
+ * @returns The worktree if found, or null
286
+ */
287
+ export function findWorktreeByName(name: string): Worktree | null {
288
+ const worktrees = listWorktrees();
289
+ return worktrees.find(wt => wt.branch === name) || null;
290
+ }
291
+
292
+ /**
293
+ * Get the main repository's worktree (the first in the list).
294
+ *
295
+ * @returns The main repository worktree, or null if not in a repo
296
+ */
297
+ export function getMainWorktree(): Worktree | null {
298
+ const worktrees = listWorktrees();
299
+ return worktrees.length > 0 ? worktrees[0] : null;
300
+ }
301
+
302
+ /**
303
+ * Handle the 'worktree reset [name]' command.
304
+ * Resets a worktree to the current HEAD revision.
305
+ *
306
+ * @param name - Optional worktree name (branch). If not provided, resets current worktree.
307
+ * @returns Exit code (0 for success, 1 for error)
308
+ */
309
+ export function handleWorktreeReset(name: string | undefined): number {
310
+ // Check if in a git repository
311
+ if (!isGitRepository()) {
312
+ error('Error: Not in a git repository');
313
+ return 1;
314
+ }
315
+
316
+ let targetWorktree: Worktree | null;
317
+
318
+ if (name) {
319
+ // Find worktree by name
320
+ targetWorktree = findWorktreeByName(name);
321
+ if (!targetWorktree) {
322
+ error(`Error: Worktree '${name}' not found`);
323
+ return 1;
324
+ }
325
+ } else {
326
+ // Use current worktree
327
+ targetWorktree = getCurrentWorktree();
328
+ if (!targetWorktree) {
329
+ error(
330
+ 'Error: Not in a worktree. Specify a worktree name or run from within a worktree.',
331
+ );
332
+ return 1;
333
+ }
334
+ }
335
+
336
+ // Get the main repository to get its HEAD commit
337
+ const mainWorktree = getMainWorktree();
338
+ if (!mainWorktree) {
339
+ error('Error: Could not find main repository');
340
+ return 1;
341
+ }
342
+
343
+ // Get current HEAD commit from the main repository
344
+ let targetRevision: string;
345
+ try {
346
+ targetRevision = execSync('git rev-parse HEAD', {
347
+ cwd: mainWorktree.path,
348
+ encoding: 'utf-8',
349
+ }).trim();
350
+ } catch (err) {
351
+ if (err instanceof Error) {
352
+ error(`Error: Failed to get current HEAD commit: ${err.message}`);
353
+ } else {
354
+ error('Error: Failed to get current HEAD commit');
355
+ }
356
+ return 1;
357
+ }
358
+
359
+ // Check for uncommitted changes
360
+ try {
361
+ if (hasUncommittedChanges(targetWorktree.path)) {
362
+ error('Error: Cannot reset worktree with uncommitted changes');
363
+ console.error(`Worktree '${targetWorktree.branch}' at ${targetWorktree.path}`);
364
+ console.error('\nPlease commit or stash changes and try again.');
365
+ console.error('You can check the status by running: git status');
366
+ return 1;
367
+ }
368
+ } catch (err) {
369
+ if (err instanceof Error) {
370
+ error(err.message);
371
+ } else {
372
+ error(`Failed to check worktree '${targetWorktree.branch}'`);
373
+ }
374
+ return 1;
375
+ }
376
+
377
+ // Reset the worktree
378
+ info(`Resetting worktree ${targetWorktree.branch}...`);
379
+ try {
380
+ resetWorktree(targetWorktree.path, targetRevision);
381
+ } catch (err) {
382
+ if (err instanceof Error) {
383
+ error(err.message);
384
+ } else {
385
+ error(`Failed to reset worktree '${targetWorktree.branch}'`);
386
+ }
387
+ return 1;
388
+ }
389
+
390
+ success(`Successfully reset worktree '${targetWorktree.branch}'`);
391
+ return 0;
392
+ }
393
+
394
+ /**
395
+ * Handle the 'worktree' command routing.
396
+ *
397
+ * @param args - Command arguments after 'worktree'
398
+ * @returns Exit code (0 for success, 1 for error)
399
+ */
400
+ export async function handleWorktree(args: string[]): Promise<number> {
401
+ const [subcommand, ...restArgs] = args;
402
+
403
+ if (subcommand === 'new') {
404
+ // Parse arguments
405
+ const detach = restArgs.includes('--detach');
406
+ const name = restArgs.find(arg => !arg.startsWith('--'));
407
+ return handleWorktreeNew(name, detach);
408
+ }
409
+
410
+ if (subcommand === 'reset') {
411
+ const name = restArgs.find(arg => !arg.startsWith('--'));
412
+ return handleWorktreeReset(name);
413
+ }
414
+
415
+ if (!subcommand) {
416
+ error('Error: worktree command requires a subcommand');
417
+ console.error("Run 'af help worktree' for more information.");
418
+ return 1;
419
+ }
420
+
421
+ error(`Error: Unknown worktree subcommand: ${subcommand}`);
422
+ console.error("Run 'af help worktree' for available subcommands.");
423
+ return 1;
424
+ }
@@ -0,0 +1,71 @@
1
+ import { Box, Text, useApp, useInput } from 'ink';
2
+ import React, { useState } from 'react';
3
+ import type { OngoingChange } from '../utils/openspec.ts';
4
+
5
+ /**
6
+ * Props for ChangeSelect component
7
+ */
8
+ export interface ChangeSelectProps {
9
+ /** Array of ongoing changes to choose from */
10
+ changes: OngoingChange[];
11
+ /** Callback when a change is selected */
12
+ onSelect: (changeId: string) => void;
13
+ /** Callback when selection is cancelled */
14
+ onCancel: () => void;
15
+ /** Custom prompt text to display (default: "Select a change:") */
16
+ prompt?: string;
17
+ }
18
+
19
+ /**
20
+ * Interactive change selection component for commands that need to select from ongoing changes.
21
+ * Allows users to select from multiple ongoing changes using keyboard navigation.
22
+ */
23
+ export function ChangeSelect({
24
+ changes,
25
+ onSelect,
26
+ onCancel,
27
+ prompt = 'Select a change:',
28
+ }: ChangeSelectProps) {
29
+ const [selectedIndex, setSelectedIndex] = useState(0);
30
+ const { exit } = useApp();
31
+
32
+ useInput((input, key) => {
33
+ if (key.upArrow) {
34
+ setSelectedIndex(prev => (prev > 0 ? prev - 1 : changes.length - 1));
35
+ } else if (key.downArrow) {
36
+ setSelectedIndex(prev => (prev < changes.length - 1 ? prev + 1 : 0));
37
+ } else if (key.return) {
38
+ const selectedChange = changes[selectedIndex];
39
+ onSelect(selectedChange.id);
40
+ } else if (key.escape || (key.ctrl && input === 'c')) {
41
+ onCancel();
42
+ exit();
43
+ }
44
+ });
45
+
46
+ return (
47
+ <Box flexDirection="column">
48
+ <Box marginBottom={1}>
49
+ <Text bold color="cyan">
50
+ {prompt}
51
+ </Text>
52
+ </Box>
53
+ {changes.map((change, index) => {
54
+ const isHighlighted = index === selectedIndex;
55
+ const indicator = isHighlighted ? '›' : ' ';
56
+
57
+ return (
58
+ <Box key={change.id}>
59
+ <Text color={isHighlighted ? 'cyan' : undefined}>
60
+ {indicator} {change.id}
61
+ </Text>
62
+ <Text color="gray"> ({change.status})</Text>
63
+ </Box>
64
+ );
65
+ })}
66
+ <Box marginTop={1}>
67
+ <Text color="gray">↑/↓ to navigate, Enter to select, Esc to cancel</Text>
68
+ </Box>
69
+ </Box>
70
+ );
71
+ }
@@ -0,0 +1,41 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+
3
+ /**
4
+ * Props for Confirm component
5
+ */
6
+ export interface ConfirmProps {
7
+ /** The question to ask the user */
8
+ message: string;
9
+ /** Default value (default: false) */
10
+ defaultValue?: boolean;
11
+ /** Callback when user confirms (true) or denies (false) */
12
+ onConfirm?: (confirmed: boolean) => void;
13
+ }
14
+
15
+ /**
16
+ * Confirm component for yes/no prompts
17
+ * Accepts y/n or yes/no input
18
+ */
19
+ export function Confirm({ message, defaultValue = false, onConfirm }: ConfirmProps) {
20
+ useInput(input => {
21
+ const normalized = input.toLowerCase();
22
+
23
+ if (normalized === 'y' || normalized === 'yes') {
24
+ onConfirm?.(true);
25
+ } else if (normalized === 'n' || normalized === 'no') {
26
+ onConfirm?.(false);
27
+ } else if (input === '') {
28
+ // Enter with no input uses default
29
+ onConfirm?.(defaultValue);
30
+ }
31
+ });
32
+
33
+ const hint = defaultValue ? '(Y/n)' : '(y/N)';
34
+
35
+ return (
36
+ <Box>
37
+ <Text>{message} </Text>
38
+ <Text color="gray">{hint}</Text>
39
+ </Box>
40
+ );
41
+ }
@@ -0,0 +1,52 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import type { ConflictResolution } from '../utils/setup-files.ts';
3
+
4
+ /**
5
+ * Props for FileConflict component
6
+ */
7
+ export interface FileConflictProps {
8
+ /** The file path that has a conflict */
9
+ filePath: string;
10
+ /** Callback when user makes a resolution choice */
11
+ onResolve: (resolution: ConflictResolution) => void;
12
+ }
13
+
14
+ /**
15
+ * Interactive component for resolving file conflicts during setup.
16
+ * Displays the conflicting file path and accepts keyboard input:
17
+ * - y: Overwrite this file
18
+ * - n: Skip this file
19
+ * - a: Overwrite all remaining files
20
+ * - s: Skip all remaining files
21
+ */
22
+ export function FileConflict({ filePath, onResolve }: FileConflictProps) {
23
+ useInput(input => {
24
+ const key = input.toLowerCase();
25
+ switch (key) {
26
+ case 'y':
27
+ onResolve('overwrite');
28
+ break;
29
+ case 'n':
30
+ onResolve('skip');
31
+ break;
32
+ case 'a':
33
+ onResolve('overwrite-all');
34
+ break;
35
+ case 's':
36
+ onResolve('skip-all');
37
+ break;
38
+ }
39
+ });
40
+
41
+ return (
42
+ <Box flexDirection="column">
43
+ <Box>
44
+ <Text color="yellow">File exists: </Text>
45
+ <Text>{filePath}</Text>
46
+ </Box>
47
+ <Box marginTop={1}>
48
+ <Text color="gray">[y] Overwrite [n] Skip [a] Overwrite all [s] Skip all</Text>
49
+ </Box>
50
+ </Box>
51
+ );
52
+ }
@@ -0,0 +1,53 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useState } from 'react';
3
+
4
+ /**
5
+ * Props for TextInput component
6
+ */
7
+ export interface TextInputProps {
8
+ /** Placeholder text to display when input is empty */
9
+ placeholder?: string;
10
+ /** Initial value of the input */
11
+ value?: string;
12
+ /** Callback when input value changes */
13
+ onChange?: (value: string) => void;
14
+ /** Callback when user presses Enter */
15
+ onSubmit?: (value: string) => void;
16
+ }
17
+
18
+ /**
19
+ * TextInput component for user text input
20
+ * Provides controlled text input with keyboard handling
21
+ */
22
+ export function TextInput({
23
+ placeholder = '',
24
+ value: initialValue = '',
25
+ onChange,
26
+ onSubmit,
27
+ }: TextInputProps) {
28
+ const [value, setValue] = useState(initialValue);
29
+
30
+ useInput((input, key) => {
31
+ if (key.return) {
32
+ // Enter key pressed
33
+ onSubmit?.(value);
34
+ } else if (key.backspace || key.delete) {
35
+ // Backspace/Delete key pressed
36
+ const newValue = value.slice(0, -1);
37
+ setValue(newValue);
38
+ onChange?.(newValue);
39
+ } else if (!key.ctrl && !key.meta && !key.escape) {
40
+ // Regular character input
41
+ const newValue = value + input;
42
+ setValue(newValue);
43
+ onChange?.(newValue);
44
+ }
45
+ });
46
+
47
+ return (
48
+ <Box>
49
+ <Text>{value || <Text color="gray">{placeholder}</Text>}</Text>
50
+ <Text color="cyan">█</Text>
51
+ </Box>
52
+ );
53
+ }