@cod3vil/trunk 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +69 -0
  4. package/dist/commands/clone.d.ts +6 -0
  5. package/dist/commands/clone.js +114 -0
  6. package/dist/commands/init.d.ts +6 -0
  7. package/dist/commands/init.js +235 -0
  8. package/dist/commands/new.d.ts +6 -0
  9. package/dist/commands/new.js +357 -0
  10. package/dist/core/adopt.d.ts +14 -0
  11. package/dist/core/adopt.js +157 -0
  12. package/dist/core/agents.d.ts +30 -0
  13. package/dist/core/agents.js +31 -0
  14. package/dist/core/arguments.d.ts +92 -0
  15. package/dist/core/arguments.js +93 -0
  16. package/dist/core/detect.d.ts +28 -0
  17. package/dist/core/detect.js +169 -0
  18. package/dist/core/diff.d.ts +37 -0
  19. package/dist/core/diff.js +140 -0
  20. package/dist/core/env.d.ts +63 -0
  21. package/dist/core/env.js +140 -0
  22. package/dist/core/generate/aliases.d.ts +7 -0
  23. package/dist/core/generate/aliases.js +45 -0
  24. package/dist/core/generate/header.d.ts +2 -0
  25. package/dist/core/generate/header.js +81 -0
  26. package/dist/core/generate/index.d.ts +8 -0
  27. package/dist/core/generate/index.js +74 -0
  28. package/dist/core/generate/proxy.d.ts +8 -0
  29. package/dist/core/generate/proxy.js +56 -0
  30. package/dist/core/generate/steps.d.ts +7 -0
  31. package/dist/core/generate/steps.js +88 -0
  32. package/dist/core/generate/tmux.d.ts +4 -0
  33. package/dist/core/generate/tmux.js +98 -0
  34. package/dist/core/generate/toml.d.ts +16 -0
  35. package/dist/core/generate/toml.js +68 -0
  36. package/dist/core/gh.d.ts +60 -0
  37. package/dist/core/gh.js +101 -0
  38. package/dist/core/git.d.ts +79 -0
  39. package/dist/core/git.js +211 -0
  40. package/dist/core/journal.d.ts +54 -0
  41. package/dist/core/journal.js +147 -0
  42. package/dist/core/log.d.ts +8 -0
  43. package/dist/core/log.js +38 -0
  44. package/dist/core/pipeline.d.ts +119 -0
  45. package/dist/core/pipeline.js +473 -0
  46. package/dist/core/platform.d.ts +10 -0
  47. package/dist/core/platform.js +26 -0
  48. package/dist/core/prefix.d.ts +25 -0
  49. package/dist/core/prefix.js +59 -0
  50. package/dist/core/process.d.ts +27 -0
  51. package/dist/core/process.js +43 -0
  52. package/dist/core/repo.d.ts +79 -0
  53. package/dist/core/repo.js +294 -0
  54. package/dist/core/resolve.d.ts +127 -0
  55. package/dist/core/resolve.js +488 -0
  56. package/dist/core/result.d.ts +27 -0
  57. package/dist/core/result.js +32 -0
  58. package/dist/core/settings.d.ts +51 -0
  59. package/dist/core/settings.js +83 -0
  60. package/dist/core/tmuxRename.d.ts +36 -0
  61. package/dist/core/tmuxRename.js +79 -0
  62. package/dist/core/validate.d.ts +22 -0
  63. package/dist/core/validate.js +111 -0
  64. package/dist/core/version.d.ts +2 -0
  65. package/dist/core/version.js +35 -0
  66. package/dist/core/words.d.ts +16 -0
  67. package/dist/core/words.js +198 -0
  68. package/dist/core/wt.d.ts +41 -0
  69. package/dist/core/wt.js +59 -0
  70. package/dist/ui/SetupForm.d.ts +55 -0
  71. package/dist/ui/SetupForm.js +354 -0
  72. package/dist/ui/Summary.d.ts +15 -0
  73. package/dist/ui/Summary.js +74 -0
  74. package/dist/ui/fields/MultiSelect.d.ts +17 -0
  75. package/dist/ui/fields/MultiSelect.js +66 -0
  76. package/dist/ui/fields/Select.d.ts +17 -0
  77. package/dist/ui/fields/Select.js +37 -0
  78. package/dist/ui/fields/TextInput.d.ts +13 -0
  79. package/dist/ui/fields/TextInput.js +50 -0
  80. package/package.json +76 -0
  81. package/readme.md +147 -0
@@ -0,0 +1,354 @@
1
+ /* eslint-disable unicorn/filename-case -- Phase 3 specifies SetupForm.tsx. */
2
+ /** The only interactive surface in trunk: setup fields followed by a preview. */
3
+ import { spawn } from 'node:child_process';
4
+ import process from 'node:process';
5
+ import React, { useRef, useState } from 'react';
6
+ import { Box, Text, render, useApp, useInput } from 'ink';
7
+ import { agentIds, maximumAgents } from '../core/agents.js';
8
+ import { buildRerunCommand, resolve, setupFieldOrder, } from '../core/resolve.js';
9
+ import { supportsInteractiveInput } from '../core/platform.js';
10
+ import { validatePrefix } from '../core/prefix.js';
11
+ import { badUsage, userAborted } from '../core/result.js';
12
+ import { randomWord } from '../core/words.js';
13
+ import Summary from './Summary.js';
14
+ import MultiSelect from './fields/MultiSelect.js';
15
+ import Select from './fields/Select.js';
16
+ import TextInput from './fields/TextInput.js';
17
+ const onOffOptions = Object.freeze([
18
+ Object.freeze({ value: 'on', label: 'on' }),
19
+ Object.freeze({ value: 'off', label: 'off' }),
20
+ ]);
21
+ export default function SetupForm({ folder, options, randomPrefix = randomWord, onSubmit, onAbort, }) {
22
+ const { exit } = useApp();
23
+ const finished = useRef(false);
24
+ const [values, setValues] = useState(() => valuesFromSettings(resolve({ ...options, acceptDefaults: false }).draft));
25
+ const [activeField, setActiveField] = useState(() => firstVisibleField(values, options));
26
+ const [summary, setSummary] = useState();
27
+ const [formError, setFormError] = useState();
28
+ const visibleFields = visibleSetupFields(values, options);
29
+ useInput((input, key) => {
30
+ if (key.ctrl && input === 'c') {
31
+ abort();
32
+ }
33
+ });
34
+ if (summary) {
35
+ return (React.createElement(Summary, { folder: folder, settings: summary, onConfirm: () => {
36
+ if (finished.current) {
37
+ return;
38
+ }
39
+ finished.current = true;
40
+ onSubmit(summary);
41
+ exit();
42
+ }, onBack: () => {
43
+ setSummary(undefined);
44
+ setActiveField(visibleFields.at(-1) ?? 'prefix');
45
+ }, onAbort: abort }));
46
+ }
47
+ return (React.createElement(Box, { flexDirection: "column", width: "100%" },
48
+ React.createElement(Text, { bold: true }, "Configure worktree setup"),
49
+ React.createElement(Box, { marginTop: 1 },
50
+ React.createElement(Text, { dimColor: true }, ' folder'.padEnd(20)),
51
+ React.createElement(Text, { wrap: "truncate-end" }, folder)),
52
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, visibleFields.map(field => renderField(field))),
53
+ formError ? (React.createElement(Box, { marginTop: 1 },
54
+ React.createElement(Text, { color: "red" }, formError))) : null,
55
+ React.createElement(Box, { marginTop: 1 },
56
+ React.createElement(Text, { dimColor: true }, "arrows choose \u00B7 space checks \u00B7 Enter continues \u00B7 Ctrl+C aborts"))));
57
+ function renderField(field) {
58
+ const active = field === activeField;
59
+ switch (field) {
60
+ case 'prefix': {
61
+ const validation = validatePrefix(values.prefix);
62
+ return (React.createElement(TextInput, { key: field, label: "prefix", value: values.prefix, isActive: active, help: "keep it unique across your repos", validationError: validation.valid ? undefined : validation.reason, randomValue: randomPrefix, onChange: value => {
63
+ update('prefix', value);
64
+ }, onSubmit: () => {
65
+ advance(field);
66
+ } }));
67
+ }
68
+ case 'pm': {
69
+ return (React.createElement(Select, { key: field, label: "package manager", options: [
70
+ { value: 'npm', label: 'npm' },
71
+ { value: 'pnpm', label: 'pnpm' },
72
+ { value: 'bun', label: 'bun' },
73
+ ], value: values.pm, isActive: active, note: options.packageDetection?.needsConfirmation
74
+ ? 'confirm'
75
+ : undefined, onChange: value => {
76
+ update('pm', value);
77
+ }, onSubmit: () => {
78
+ advance(field);
79
+ } }));
80
+ }
81
+ case 'tmux': {
82
+ return booleanField(field, 'tmux session', values.tmux, options.tools?.tmux.path ? undefined : 'not installed');
83
+ }
84
+ case 'agents': {
85
+ return (React.createElement(MultiSelect, { key: field, label: "agents", options: agentIds.map(id => ({
86
+ value: id,
87
+ label: id,
88
+ note: options.tools?.agents[id].path
89
+ ? undefined
90
+ : 'not installed',
91
+ })), value: values.agents, maximum: maximumAgents, isActive: active, onChange: value => {
92
+ update('agents', value);
93
+ }, onSubmit: () => {
94
+ advance(field);
95
+ } }));
96
+ }
97
+ case 'copyIgnored': {
98
+ return booleanField(field, 'copy-ignored', values.copyIgnored);
99
+ }
100
+ case 'server': {
101
+ return booleanField(field, 'dev server', values.server);
102
+ }
103
+ case 'caddy': {
104
+ const missing = options.tools?.caddy.path === undefined;
105
+ return booleanField(field, missing ? 'include anyway' : 'Caddy route', values.caddy, missing ? '(teammates may have it)' : undefined);
106
+ }
107
+ case 'mcAlias': {
108
+ return booleanField(field, 'mc alias', values.mcAlias);
109
+ }
110
+ }
111
+ }
112
+ function booleanField(field, label, value, note) {
113
+ return (React.createElement(Select, { key: field, label: label, options: onOffOptions, value: value ? 'on' : 'off', isActive: field === activeField, note: note, onChange: next => {
114
+ update(field, next === 'on');
115
+ }, onSubmit: () => {
116
+ advance(field);
117
+ } }));
118
+ }
119
+ function update(field, value) {
120
+ setValues(current => ({ ...current, [field]: value }));
121
+ setFormError(undefined);
122
+ }
123
+ function advance(field) {
124
+ const fields = visibleSetupFields(values, options);
125
+ const next = fields[fields.indexOf(field) + 1];
126
+ if (next) {
127
+ setActiveField(next);
128
+ return;
129
+ }
130
+ try {
131
+ const resolution = resolve({
132
+ ...options,
133
+ acceptDefaults: false,
134
+ answers: { ...options.answers, ...values },
135
+ });
136
+ if (resolution.kind === 'questions') {
137
+ setActiveField(resolution.questions[0]?.field ?? 'prefix');
138
+ setFormError('Answer every visible field before continuing.');
139
+ return;
140
+ }
141
+ setSummary(resolution.settings);
142
+ }
143
+ catch (error) {
144
+ setFormError(errorMessage(error));
145
+ }
146
+ }
147
+ function abort() {
148
+ if (finished.current) {
149
+ return;
150
+ }
151
+ finished.current = true;
152
+ onAbort();
153
+ exit();
154
+ }
155
+ }
156
+ /** Mounts Ink with Ctrl+C routed through the form's normal abort result. */
157
+ export async function runSetupForm(properties) {
158
+ let outcome;
159
+ if (!supportsInteractiveInput()) {
160
+ return {
161
+ kind: 'unavailable',
162
+ reason: 'stdin is not an interactive terminal',
163
+ };
164
+ }
165
+ try {
166
+ const app = render(React.createElement(SetupForm, { ...properties, onSubmit: settings => {
167
+ outcome = { kind: 'settings', settings };
168
+ }, onAbort: () => {
169
+ outcome = { kind: 'aborted' };
170
+ } }), { exitOnCtrlC: false });
171
+ await app.waitUntilExit();
172
+ app.cleanup();
173
+ }
174
+ catch (error) {
175
+ // Ink throws from inside React when raw mode turns out to be unusable,
176
+ // which would otherwise reach the user as a component stack.
177
+ return { kind: 'unavailable', reason: errorMessage(error) };
178
+ }
179
+ return outcome ?? { kind: 'aborted' };
180
+ }
181
+ /** Chooses the headless path or mounts the form, including the Caddy branch. */
182
+ export async function collectSettings({ folder, resolveOptions, invocation, yes = false, interactive = supportsInteractiveInput(), randomPrefix, installCaddy = streamCaddyInstall, report = line => {
183
+ process.stderr.write(`${line}\n`);
184
+ }, runForm = runSetupForm, askInstall = askToInstallCaddy, }) {
185
+ let options = { ...resolveOptions, acceptDefaults: yes };
186
+ let resolution;
187
+ try {
188
+ resolution = resolve(options);
189
+ }
190
+ catch (error) {
191
+ return { kind: 'outcome', outcome: badUsage(errorMessage(error)) };
192
+ }
193
+ if (resolution.kind === 'complete') {
194
+ return { kind: 'settings', settings: resolution.settings };
195
+ }
196
+ if (!interactive) {
197
+ return {
198
+ kind: 'outcome',
199
+ outcome: badUsage(missingInputMessage(resolution, invocation)),
200
+ };
201
+ }
202
+ const caddy = resolution.questions.find((question) => question.field === 'caddy');
203
+ if (caddy?.availability.kind === 'brew-installable') {
204
+ const answer = await askInstall();
205
+ if (answer === 'abort') {
206
+ return { kind: 'outcome', outcome: userAborted() };
207
+ }
208
+ if (answer === 'yes') {
209
+ let installed = false;
210
+ try {
211
+ installed = await installCaddy(caddy.availability.executable, caddy.availability.arguments);
212
+ }
213
+ catch {
214
+ // A missing/broken Brew process is the same as a non-zero install.
215
+ }
216
+ if (installed) {
217
+ options = withCaddyInstalled(options);
218
+ resolution = resolve(options);
219
+ }
220
+ else {
221
+ report('brew install caddy failed; continuing without it.');
222
+ report(caddyInstallUrl());
223
+ }
224
+ }
225
+ else {
226
+ report(caddyInstallUrl());
227
+ }
228
+ }
229
+ else if (caddy?.availability.kind === 'missing') {
230
+ report(caddy.availability.installUrl);
231
+ }
232
+ if (resolution.kind === 'complete') {
233
+ return { kind: 'settings', settings: resolution.settings };
234
+ }
235
+ const form = await runForm({ folder, options, randomPrefix });
236
+ if (form.kind === 'settings') {
237
+ return form;
238
+ }
239
+ if (form.kind === 'unavailable') {
240
+ return {
241
+ kind: 'outcome',
242
+ outcome: badUsage(missingInputMessage(resolution, invocation, form.reason)),
243
+ };
244
+ }
245
+ return { kind: 'outcome', outcome: userAborted() };
246
+ }
247
+ /**
248
+ * What to say when the questions cannot be asked: the exact command that
249
+ * answers them, rather than a complaint about the terminal.
250
+ */
251
+ function missingInputMessage(resolution, invocation, reason) {
252
+ const rerun = buildRerunCommand(invocation.executable, invocation.arguments, resolution);
253
+ const cause = reason
254
+ ? `Interactive setup needs a terminal that can read keys (${reason}).`
255
+ : 'Interactive setup requires a TTY.';
256
+ return `${cause} Re-run with --yes, or specify the missing flags:\n ${rerun.display}`;
257
+ }
258
+ function CaddyInstallPrompt({ onAnswer, }) {
259
+ const { exit } = useApp();
260
+ const answered = useRef(false);
261
+ useInput((input, key) => {
262
+ let answer;
263
+ if (key.ctrl && input === 'c') {
264
+ answer = 'abort';
265
+ }
266
+ else if (key.return || input.toLowerCase() === 'y') {
267
+ answer = 'yes';
268
+ }
269
+ else if (input.toLowerCase() === 'n') {
270
+ answer = 'no';
271
+ }
272
+ if (!answer || answered.current) {
273
+ return;
274
+ }
275
+ answered.current = true;
276
+ onAnswer(answer);
277
+ exit();
278
+ });
279
+ return (React.createElement(Text, null, "? caddy not found. Install with 'brew install caddy'? (Y/n)"));
280
+ }
281
+ async function askToInstallCaddy() {
282
+ let answer = 'abort';
283
+ const app = render(React.createElement(CaddyInstallPrompt, { onAnswer: value => {
284
+ answer = value;
285
+ } }), { exitOnCtrlC: false });
286
+ await app.waitUntilExit();
287
+ app.cleanup();
288
+ return answer;
289
+ }
290
+ async function streamCaddyInstall(executable, arguments_) {
291
+ return new Promise(resolve => {
292
+ // Inherited stdio: the user watches Brew's own progress output.
293
+ const child = spawn(executable, [...arguments_], { stdio: 'inherit' });
294
+ child.once('error', () => {
295
+ resolve(false);
296
+ });
297
+ child.once('close', code => {
298
+ resolve(code === 0);
299
+ });
300
+ });
301
+ }
302
+ function withCaddyInstalled(options) {
303
+ if (!options.tools) {
304
+ return options;
305
+ }
306
+ return {
307
+ ...options,
308
+ tools: {
309
+ ...options.tools,
310
+ caddy: Object.freeze({ name: 'caddy', path: 'caddy' }),
311
+ },
312
+ };
313
+ }
314
+ function visibleSetupFields(values, options) {
315
+ return setupFieldOrder.filter(field => {
316
+ if (provided(field, options)) {
317
+ return false;
318
+ }
319
+ return !((field === 'agents' && !values.tmux) ||
320
+ (field === 'caddy' && !values.server));
321
+ });
322
+ }
323
+ function firstVisibleField(values, options) {
324
+ return visibleSetupFields(values, options)[0] ?? 'prefix';
325
+ }
326
+ function provided(field, options) {
327
+ if (Object.hasOwn(options.answers ?? {}, field)) {
328
+ return true;
329
+ }
330
+ const { flags } = options;
331
+ if (!flags) {
332
+ return false;
333
+ }
334
+ const value = flags[field];
335
+ return value !== undefined;
336
+ }
337
+ function valuesFromSettings(settings) {
338
+ return {
339
+ prefix: settings.prefix,
340
+ pm: settings.pm,
341
+ tmux: settings.tmux,
342
+ agents: settings.agents,
343
+ copyIgnored: settings.copyIgnored,
344
+ server: settings.server,
345
+ caddy: settings.caddy,
346
+ mcAlias: settings.mcAlias,
347
+ };
348
+ }
349
+ function caddyInstallUrl() {
350
+ return 'https://caddyserver.com/docs/install';
351
+ }
352
+ function errorMessage(error) {
353
+ return error instanceof Error ? error.message : String(error);
354
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { type Settings } from '../core/settings.js';
3
+ export type SummaryProperties = Readonly<{
4
+ folder: string;
5
+ settings: Settings;
6
+ onConfirm: () => void;
7
+ onBack: () => void;
8
+ onAbort: () => void;
9
+ }>;
10
+ export type SummaryRow = Readonly<{
11
+ label: string;
12
+ value: string;
13
+ }>;
14
+ export declare function summaryRows(settings: Settings, folder: string): readonly SummaryRow[];
15
+ export default function Summary({ folder, settings, onConfirm, onBack, onAbort, }: SummaryProperties): React.ReactElement;
@@ -0,0 +1,74 @@
1
+ /* eslint-disable unicorn/filename-case -- Phase 3 specifies Summary.tsx. */
2
+ /** Final preview shown before a command is allowed to write wt.toml. */
3
+ import { homedir } from 'node:os';
4
+ import React from 'react';
5
+ import { Box, Text, useInput } from 'ink';
6
+ import { developmentCommand, installCommand } from '../core/generate/steps.js';
7
+ import { routeUrl } from '../core/generate/proxy.js';
8
+ import { usesCaddy } from '../core/settings.js';
9
+ export function summaryRows(settings, folder) {
10
+ const steps = [
11
+ settings.tmux ? 'tmux' : undefined,
12
+ settings.copyIgnored ? 'copy-ignored' : undefined,
13
+ 'install',
14
+ settings.server ? 'server' : undefined,
15
+ usesCaddy(settings) ? 'proxy' : undefined,
16
+ settings.mcAlias ? 'mc' : undefined,
17
+ ].filter((step) => step !== undefined);
18
+ return Object.freeze([
19
+ Object.freeze({ label: 'folder', value: displayPath(folder) }),
20
+ Object.freeze({
21
+ label: 'prefix',
22
+ value: `${settings.prefix} session ${settings.tmux ? `${settings.prefix}_<branch>` : 'off'}`,
23
+ }),
24
+ Object.freeze({ label: 'install', value: installCommand(settings) }),
25
+ Object.freeze({
26
+ label: 'server',
27
+ value: settings.server
28
+ ? developmentCommand(settings, '<hash of repo+branch>')
29
+ : 'off',
30
+ }),
31
+ Object.freeze({
32
+ label: 'route',
33
+ value: usesCaddy(settings) ? routeUrl(settings, '<branch>') : 'off',
34
+ }),
35
+ Object.freeze({
36
+ label: 'agents',
37
+ value: settings.agents.length > 0 ? settings.agents.join(', ') : 'none',
38
+ }),
39
+ Object.freeze({ label: 'steps', value: steps.join(' · ') }),
40
+ ]);
41
+ }
42
+ export default function Summary({ folder, settings, onConfirm, onBack, onAbort, }) {
43
+ useInput((input, key) => {
44
+ if (key.return) {
45
+ onConfirm();
46
+ }
47
+ else if (input === 'b') {
48
+ onBack();
49
+ }
50
+ else if (input === 'q') {
51
+ onAbort();
52
+ }
53
+ });
54
+ return (React.createElement(Box, { flexDirection: "column", width: "100%" },
55
+ React.createElement(Text, { bold: true }, "Review setup"),
56
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, summaryRows(settings, folder).map(row => (React.createElement(Box, { key: row.label, width: "100%" },
57
+ React.createElement(Box, { width: 10, flexShrink: 0 },
58
+ React.createElement(Text, { dimColor: true }, row.label)),
59
+ React.createElement(Box, { flexGrow: 1 },
60
+ React.createElement(Text, { wrap: "truncate-end" }, row.value)))))),
61
+ React.createElement(Box, { marginTop: 1 },
62
+ React.createElement(Text, { color: "cyan" }, "[Enter]"),
63
+ React.createElement(Text, null, " write \u00B7 "),
64
+ React.createElement(Text, { color: "cyan" }, "[b]"),
65
+ React.createElement(Text, null, " back \u00B7 "),
66
+ React.createElement(Text, { color: "cyan" }, "[q]"),
67
+ React.createElement(Text, null, " abort"))));
68
+ }
69
+ function displayPath(path) {
70
+ const home = homedir();
71
+ return path === home || path.startsWith(`${home}/`)
72
+ ? `~${path.slice(home.length)}`
73
+ : path;
74
+ }
@@ -0,0 +1,17 @@
1
+ /** Fixed-order checkboxes with an enforced selection limit. */
2
+ import React from 'react';
3
+ export type MultiSelectOption<Value extends string> = Readonly<{
4
+ value: Value;
5
+ label: string;
6
+ note?: string;
7
+ }>;
8
+ export type MultiSelectProperties<Value extends string> = Readonly<{
9
+ label: string;
10
+ options: ReadonlyArray<MultiSelectOption<Value>>;
11
+ value: readonly Value[];
12
+ maximum: number;
13
+ isActive: boolean;
14
+ onChange: (value: readonly Value[]) => void;
15
+ onSubmit: () => void;
16
+ }>;
17
+ export default function MultiSelect<Value extends string>({ label, options, value, maximum, isActive, onChange, onSubmit, }: MultiSelectProperties<Value>): React.ReactElement;
@@ -0,0 +1,66 @@
1
+ /* eslint-disable unicorn/filename-case -- Phase 3 specifies MultiSelect.tsx. */
2
+ /** Fixed-order checkboxes with an enforced selection limit. */
3
+ import React, { useState } from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ export default function MultiSelect({ label, options, value, maximum, isActive, onChange, onSubmit, }) {
6
+ const [cursor, setCursor] = useState(0);
7
+ const [error, setError] = useState();
8
+ useInput((input, key) => {
9
+ if (key.return) {
10
+ onSubmit();
11
+ return;
12
+ }
13
+ if (key.upArrow || key.leftArrow) {
14
+ setCursor(current => (current - 1 + options.length) % options.length);
15
+ setError(undefined);
16
+ return;
17
+ }
18
+ if (key.downArrow || key.rightArrow) {
19
+ setCursor(current => (current + 1) % options.length);
20
+ setError(undefined);
21
+ return;
22
+ }
23
+ if (input !== ' ' || options.length === 0) {
24
+ return;
25
+ }
26
+ const selected = options[cursor].value;
27
+ if (value.includes(selected)) {
28
+ onChange(value.filter(item => item !== selected));
29
+ setError(undefined);
30
+ return;
31
+ }
32
+ if (value.length >= maximum) {
33
+ setError(`max ${maximum} (2×2 grid)`);
34
+ return;
35
+ }
36
+ const next = new Set([...value, selected]);
37
+ onChange(options.map(option => option.value).filter(item => next.has(item)));
38
+ setError(undefined);
39
+ }, { isActive });
40
+ return (React.createElement(Box, { flexDirection: "column" },
41
+ React.createElement(Box, null,
42
+ React.createElement(Text, { color: isActive ? 'cyan' : undefined },
43
+ isActive ? '›' : ' ',
44
+ " ",
45
+ label),
46
+ error ? React.createElement(Text, { color: "red" },
47
+ " ",
48
+ error) : null),
49
+ React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, Array.from({ length: Math.ceil(options.length / 2) }, (_, row) => (React.createElement(Box, { key: row }, [options[row * 2], options[row * 2 + 1]]
50
+ .filter((option) => option !== undefined)
51
+ .map(option => {
52
+ const index = options.indexOf(option);
53
+ const checked = value.includes(option.value);
54
+ return (React.createElement(Box, { key: option.value, width: 36 },
55
+ React.createElement(Text, { color: isActive && cursor === index ? 'cyan' : undefined },
56
+ isActive && cursor === index ? '›' : ' ',
57
+ ' ',
58
+ checked ? '[x]' : '[ ]',
59
+ " ",
60
+ option.label),
61
+ option.note ? (React.createElement(Text, { dimColor: true },
62
+ " (",
63
+ option.note,
64
+ ")")) : null));
65
+ })))))));
66
+ }
@@ -0,0 +1,17 @@
1
+ /** A compact single-choice field controlled with either pair of arrow keys. */
2
+ import React from 'react';
3
+ export type SelectOption<Value extends string> = Readonly<{
4
+ value: Value;
5
+ label: string;
6
+ note?: string;
7
+ }>;
8
+ export type SelectProperties<Value extends string> = Readonly<{
9
+ label: string;
10
+ options: ReadonlyArray<SelectOption<Value>>;
11
+ value: Value;
12
+ isActive: boolean;
13
+ note?: string;
14
+ onChange: (value: Value) => void;
15
+ onSubmit: () => void;
16
+ }>;
17
+ export default function Select<Value extends string>({ label, options, value, isActive, note, onChange, onSubmit, }: SelectProperties<Value>): React.ReactElement;
@@ -0,0 +1,37 @@
1
+ /* eslint-disable unicorn/filename-case -- Phase 3 specifies Select.tsx. */
2
+ /** A compact single-choice field controlled with either pair of arrow keys. */
3
+ import React from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ export default function Select({ label, options, value, isActive, note, onChange, onSubmit, }) {
6
+ useInput((_input, key) => {
7
+ if (key.return) {
8
+ onSubmit();
9
+ return;
10
+ }
11
+ const offset = key.leftArrow || key.upArrow
12
+ ? -1
13
+ : key.rightArrow || key.downArrow
14
+ ? 1
15
+ : 0;
16
+ if (offset === 0 || options.length === 0) {
17
+ return;
18
+ }
19
+ const current = options.findIndex(option => option.value === value);
20
+ const index = (current + offset + options.length) % options.length;
21
+ onChange(options[index].value);
22
+ }, { isActive });
23
+ const selected = options.find(option => option.value === value) ?? options[0];
24
+ return (React.createElement(Box, null,
25
+ React.createElement(Text, { color: isActive ? 'cyan' : undefined },
26
+ isActive ? '›' : ' ',
27
+ " ",
28
+ label.padEnd(17)),
29
+ React.createElement(Text, { bold: isActive }, selected?.label ?? value),
30
+ selected?.note ? React.createElement(Text, { dimColor: true },
31
+ " (",
32
+ selected.note,
33
+ ")") : null,
34
+ note ? React.createElement(Text, { color: "yellow" },
35
+ " ",
36
+ note) : null));
37
+ }
@@ -0,0 +1,13 @@
1
+ /** Prefix entry with random/custom shortcuts and live validation. */
2
+ import React from 'react';
3
+ export type TextInputProperties = Readonly<{
4
+ label: string;
5
+ value: string;
6
+ isActive: boolean;
7
+ help: string;
8
+ validationError?: string;
9
+ randomValue: () => string;
10
+ onChange: (value: string) => void;
11
+ onSubmit: () => void;
12
+ }>;
13
+ export default function TextInput({ label, value, isActive, help, validationError, randomValue, onChange, onSubmit, }: TextInputProperties): React.ReactElement;
@@ -0,0 +1,50 @@
1
+ /* eslint-disable unicorn/filename-case -- Phase 3 specifies TextInput.tsx. */
2
+ /** Prefix entry with random/custom shortcuts and live validation. */
3
+ import React, { useState } from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ export default function TextInput({ label, value, isActive, help, validationError, randomValue, onChange, onSubmit, }) {
6
+ const [custom, setCustom] = useState(false);
7
+ useInput((input, key) => {
8
+ if (key.ctrl || key.meta) {
9
+ return;
10
+ }
11
+ if (key.return) {
12
+ if (!validationError) {
13
+ onSubmit();
14
+ }
15
+ return;
16
+ }
17
+ if (key.backspace || key.delete) {
18
+ setCustom(true);
19
+ onChange(value.slice(0, -1));
20
+ return;
21
+ }
22
+ if (!custom && input === 'r') {
23
+ onChange(randomValue());
24
+ return;
25
+ }
26
+ if (!custom && input === 'c') {
27
+ setCustom(true);
28
+ onChange('');
29
+ return;
30
+ }
31
+ if (input &&
32
+ !key.upArrow &&
33
+ !key.downArrow &&
34
+ !key.leftArrow &&
35
+ !key.rightArrow) {
36
+ setCustom(true);
37
+ onChange(`${value}${input}`);
38
+ }
39
+ }, { isActive });
40
+ return (React.createElement(Box, { flexDirection: "column" },
41
+ React.createElement(Box, null,
42
+ React.createElement(Text, { color: isActive ? 'cyan' : undefined },
43
+ isActive ? '›' : ' ',
44
+ " ",
45
+ label.padEnd(17)),
46
+ React.createElement(Text, { bold: isActive }, value),
47
+ isActive ? React.createElement(Text, { color: "cyan" }, "\u258C") : null),
48
+ React.createElement(Box, { marginLeft: 20 },
49
+ React.createElement(Text, { color: validationError ? 'red' : undefined, dimColor: !validationError }, validationError ?? `[r] random · [c] custom · ${help}`))));
50
+ }