letmecook 0.0.13 → 0.0.15

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.
@@ -2,6 +2,8 @@ import { type CliRenderer, TextRenderable, InputRenderable, type KeyEvent } from
2
2
  import { createBaseLayout, clearLayout } from "./renderer";
3
3
  import { parseRepoSpec, type RepoSpec } from "../types";
4
4
  import { listRepoHistory } from "../repo-history";
5
+ import { showFooter, hideFooter } from "./common/footer";
6
+ import { isEnter, isEscape, isArrowUp, isArrowDown } from "./common/keyboard";
5
7
 
6
8
  export interface AddReposResult {
7
9
  repos: RepoSpec[];
@@ -11,7 +13,6 @@ export interface AddReposResult {
11
13
  export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddReposResult> {
12
14
  const history = await listRepoHistory();
13
15
  const historySpecs = history.map((item) => item.spec);
14
- let historyIndex = historySpecs.length;
15
16
  const maxMatches = 6;
16
17
 
17
18
  return new Promise((resolve) => {
@@ -21,12 +22,14 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
21
22
 
22
23
  const repos: RepoSpec[] = [];
23
24
  let currentInput = "";
24
- let lastMatchIndex = -1;
25
- let lastMatchQuery = "";
26
- let mode: "spec" | "details" = "spec";
27
- let pendingRepo: RepoSpec | null = null;
28
- let pendingReadOnly = false;
29
- let pendingLatest = false;
25
+ let currentReadOnly = false;
26
+ let currentLatest = false;
27
+ let currentValidRepo: RepoSpec | null = null;
28
+ let selectedMatchIndex = -1; // -1 means no match selected, user is typing freely
29
+ let lastQuery = ""; // Track the query that generated current matches
30
+ let isNavigating = false; // Flag to prevent input handler from resetting when navigating
31
+ let isConfirming = false; // Flag for confirmation mode (showing checkboxes)
32
+ let confirmOptionIndex = 0; // 0 = read-only, 1 = latest, 2 = confirm button
30
33
 
31
34
  // Repository input
32
35
  const repoLabel = new TextRenderable(renderer, {
@@ -67,10 +70,10 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
67
70
  });
68
71
  content.add(matchesList);
69
72
 
70
- // Details form
73
+ // Inline toggles (always visible when repo is valid)
71
74
  const detailsLabel = new TextRenderable(renderer, {
72
75
  id: "details-label",
73
- content: "\nRepository details:",
76
+ content: "",
74
77
  fg: "#e2e8f0",
75
78
  marginTop: 1,
76
79
  });
@@ -78,7 +81,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
78
81
 
79
82
  const detailsReadOnly = new TextRenderable(renderer, {
80
83
  id: "details-readonly",
81
- content: " Read-only: No",
84
+ content: "",
82
85
  fg: "#94a3b8",
83
86
  marginTop: 0,
84
87
  });
@@ -86,22 +89,40 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
86
89
 
87
90
  const detailsLatest = new TextRenderable(renderer, {
88
91
  id: "details-latest",
89
- content: " Latest: No",
92
+ content: "",
90
93
  fg: "#94a3b8",
91
94
  marginTop: 0,
92
95
  });
93
96
  content.add(detailsLatest);
94
97
 
98
+ const confirmButton = new TextRenderable(renderer, {
99
+ id: "confirm-button",
100
+ content: "",
101
+ fg: "#10b981",
102
+ marginTop: 1,
103
+ });
104
+ content.add(confirmButton);
105
+
95
106
  repoInput.onPaste = (event) => {
96
107
  const text = event.text.replace(/[\r\n]+/g, "");
97
108
  if (!text) return;
98
109
  repoInput.insertText(text);
99
110
  currentInput = repoInput.value;
100
111
  if (currentInput.trim()) {
101
- validateAndAddRepo(currentInput.trim());
112
+ const repo = validateRepo(currentInput.trim());
113
+ currentValidRepo = repo;
114
+ if (repo) {
115
+ currentReadOnly = false;
116
+ currentLatest = false;
117
+ }
102
118
  } else {
103
119
  statusText.content = "";
120
+ currentValidRepo = null;
104
121
  }
122
+ selectedMatchIndex = -1;
123
+ lastQuery = currentInput;
124
+ updateMatches();
125
+ updateDetails();
105
126
  event.preventDefault();
106
127
  };
107
128
 
@@ -114,7 +135,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
114
135
  });
115
136
  content.add(statusText);
116
137
 
117
- // Repos list
138
+ // Repos list with counter
118
139
  const reposLabel = new TextRenderable(renderer, {
119
140
  id: "repos-label",
120
141
  content: "\nAdded repositories:",
@@ -131,15 +152,6 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
131
152
  });
132
153
  content.add(reposList);
133
154
 
134
- // Instructions
135
- const instructions = new TextRenderable(renderer, {
136
- id: "instructions",
137
- content: "\n[Enter] Next [Ctrl+D] Continue [↑/↓] History [Tab] Complete [Esc] Exit",
138
- fg: "#64748b",
139
- marginTop: 2,
140
- });
141
- content.add(instructions);
142
-
143
155
  repoInput.focus();
144
156
 
145
157
  function updateReposList() {
@@ -159,43 +171,55 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
159
171
  }
160
172
 
161
173
  function updateDetails() {
162
- if (mode === "details" && pendingRepo) {
163
- detailsLabel.content = "\nRepository details:";
164
- detailsLabel.fg = "#e2e8f0";
165
- detailsReadOnly.content = ` Read-only: ${pendingReadOnly ? "Yes" : "No"}`;
166
- detailsReadOnly.fg = pendingReadOnly ? "#f59e0b" : "#94a3b8";
167
- detailsLatest.content = ` Latest: ${pendingLatest ? "Yes" : "No"}`;
168
- detailsLatest.fg = pendingLatest ? "#22d3ee" : "#94a3b8";
169
- instructions.content =
170
- "\n[Enter] Add [r] Toggle read-only [l] Toggle latest [Esc] Back";
174
+ if (isConfirming && currentValidRepo) {
175
+ // Confirmation mode with interactive checkboxes
176
+ detailsLabel.content = `\nConfigure options for: ${currentInput.trim()}`;
177
+ detailsLabel.fg = "#38bdf8";
178
+
179
+ const roCheckbox = currentReadOnly ? "[✓]" : "[ ]";
180
+ const roSelected = confirmOptionIndex === 0;
181
+ detailsReadOnly.content = ` ${roSelected ? "▶" : " "} ${roCheckbox} Read-only [r]`;
182
+ detailsReadOnly.fg = roSelected ? "#f8fafc" : currentReadOnly ? "#f59e0b" : "#94a3b8";
183
+
184
+ const latestCheckbox = currentLatest ? "[✓]" : "[ ]";
185
+ const latestSelected = confirmOptionIndex === 1;
186
+ detailsLatest.content = ` ${latestSelected ? "▶" : " "} ${latestCheckbox} Latest [l]`;
187
+ detailsLatest.fg = latestSelected ? "#f8fafc" : currentLatest ? "#22d3ee" : "#94a3b8";
188
+
189
+ const confirmSelected = confirmOptionIndex === 2;
190
+ confirmButton.content = ` ${confirmSelected ? "▶" : " "} [Add repository]`;
191
+ confirmButton.fg = confirmSelected ? "#10b981" : "#64748b";
192
+ } else if (currentValidRepo) {
193
+ detailsLabel.content = "\nPress Enter to configure options";
194
+ detailsLabel.fg = "#64748b";
195
+ detailsReadOnly.content = "";
196
+ detailsLatest.content = "";
197
+ confirmButton.content = "";
171
198
  } else {
172
- detailsLabel.content = "\nRepository details:";
173
- detailsLabel.fg = "#475569";
174
- detailsReadOnly.content = " Read-only: No";
175
- detailsReadOnly.fg = "#475569";
176
- detailsLatest.content = " Latest: No";
177
- detailsLatest.fg = "#475569";
178
- instructions.content =
179
- "\n[Enter] Next [Ctrl+D] Continue [↑/↓] History [Tab] Complete [Esc] Cancel";
199
+ detailsLabel.content = "";
200
+ detailsReadOnly.content = "";
201
+ detailsLatest.content = "";
202
+ confirmButton.content = "";
180
203
  }
181
204
  }
182
205
 
183
- function updateMatches(value: string, selectedSpec?: string) {
184
- const query = value.trim();
185
- if (!query) {
186
- matchesList.content = "(no matches)";
187
- matchesList.fg = "#64748b";
188
- return;
189
- }
206
+ function getMatchesForQuery(query: string): string[] {
207
+ const trimmed = query.trim();
208
+ if (!trimmed) return [];
190
209
 
191
- const lowerQuery = query.toLowerCase();
192
- const matches = historySpecs
210
+ const lowerQuery = trimmed.toLowerCase();
211
+ return historySpecs
193
212
  .filter((spec) => spec.toLowerCase().startsWith(lowerQuery))
194
213
  .toSorted((a, b) => {
195
214
  if (a.length !== b.length) return a.length - b.length;
196
215
  return a.localeCompare(b);
197
216
  })
198
217
  .slice(0, maxMatches);
218
+ }
219
+
220
+ function updateMatches() {
221
+ const query = lastQuery; // Use the last query, not currentInput (which may be a selected match)
222
+ const matches = getMatchesForQuery(query);
199
223
 
200
224
  if (matches.length === 0) {
201
225
  matchesList.content = "(no matches)";
@@ -205,192 +229,287 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
205
229
 
206
230
  matchesList.content = matches
207
231
  .map((spec, index) => {
208
- const isSelected = selectedSpec ? spec === selectedSpec : index === 0;
232
+ const isSelected = selectedMatchIndex === index;
209
233
  return `${isSelected ? "▶" : " "} ${spec}`;
210
234
  })
211
235
  .join("\n");
212
236
  matchesList.fg = "#94a3b8";
213
237
  }
214
238
 
215
- function getMatches(value: string): string[] {
216
- const query = value.trim();
217
- if (!query) return [];
218
- const lowerQuery = query.toLowerCase();
219
- return historySpecs
220
- .filter((spec) => spec.toLowerCase().startsWith(lowerQuery))
221
- .toSorted((a, b) => {
222
- if (a.length !== b.length) return a.length - b.length;
223
- return a.localeCompare(b);
224
- })
225
- .slice(0, maxMatches);
226
- }
227
-
228
- function validateAndAddRepo(spec: string): boolean {
239
+ function validateRepo(spec: string): RepoSpec | null {
229
240
  try {
230
- parseRepoSpec(spec);
241
+ const repo = parseRepoSpec(spec);
231
242
  statusText.content = "✓ Valid format";
232
243
  statusText.fg = "#10b981";
233
- return true;
244
+ return repo;
234
245
  } catch (error) {
235
246
  statusText.content = `✗ ${error instanceof Error ? error.message : "Invalid format"}`;
236
247
  statusText.fg = "#ef4444";
237
- return false;
248
+ return null;
238
249
  }
239
250
  }
240
251
 
241
- function applyHistorySpec(spec: string) {
242
- repoInput.value = spec;
243
- currentInput = spec;
244
- if (spec.trim()) {
245
- validateAndAddRepo(spec.trim());
246
- } else {
247
- statusText.content = "";
248
- }
249
- updateMatches(currentInput);
250
- }
252
+ function selectMatch(matchIndex: number) {
253
+ const matches = getMatchesForQuery(lastQuery);
254
+ if (matchIndex < 0 || matchIndex >= matches.length) return;
251
255
 
252
- function startRepoDetails() {
253
- const spec = currentInput.trim();
254
- if (!spec) return;
256
+ const selectedMatch = matches[matchIndex];
257
+ if (!selectedMatch) return;
255
258
 
256
- if (validateAndAddRepo(spec)) {
257
- // Check if already added
258
- if (repos.some((r) => r.spec === spec)) {
259
- statusText.content = "⚠️ Repository already added";
260
- statusText.fg = "#f59e0b";
261
- return;
262
- }
259
+ selectedMatchIndex = matchIndex;
260
+ isNavigating = true; // Set flag to prevent input handler from resetting
261
+
262
+ // Update input with selected match
263
+ repoInput.value = selectedMatch;
264
+ repoInput.cursorPosition = selectedMatch.length; // Set cursor to end
265
+ currentInput = selectedMatch;
263
266
 
264
- pendingRepo = parseRepoSpec(spec);
265
- pendingReadOnly = false;
266
- pendingLatest = false;
267
- mode = "details";
268
- repoInput.blur();
269
- updateDetails();
267
+ // Validate and update details
268
+ const repo = validateRepo(selectedMatch.trim());
269
+ currentValidRepo = repo;
270
+ if (repo) {
271
+ currentReadOnly = false;
272
+ currentLatest = false;
270
273
  }
274
+
275
+ updateMatches(); // Refresh display with new selection (matches stay the same, just highlight changes)
276
+ updateDetails();
277
+
278
+ isNavigating = false; // Reset flag
271
279
  }
272
280
 
273
- function confirmAddRepo() {
274
- if (!pendingRepo) return;
275
- pendingRepo.readOnly = pendingReadOnly;
276
- pendingRepo.latest = pendingLatest;
277
- repos.push(pendingRepo);
281
+ function addCurrentRepo() {
282
+ if (!currentValidRepo) return;
283
+
284
+ const spec = currentInput.trim();
285
+ // Check if already added
286
+ if (repos.some((r) => r.spec === spec)) {
287
+ statusText.content = "⚠️ Repository already added";
288
+ statusText.fg = "#f59e0b";
289
+ return;
290
+ }
291
+
292
+ const repoToAdd = { ...currentValidRepo };
293
+ repoToAdd.readOnly = currentReadOnly;
294
+ repoToAdd.latest = currentLatest;
295
+ repos.push(repoToAdd);
278
296
 
279
- pendingRepo = null;
280
- pendingReadOnly = false;
281
- pendingLatest = false;
282
- mode = "spec";
283
297
  currentInput = "";
284
298
  repoInput.value = "";
285
- repoInput.focus();
299
+ currentValidRepo = null;
300
+ currentReadOnly = false;
301
+ currentLatest = false;
286
302
  updateReposList();
287
- updateMatches("");
303
+ lastQuery = "";
304
+ selectedMatchIndex = -1;
305
+ updateMatches();
288
306
  updateDetails();
289
- lastMatchIndex = -1;
290
- lastMatchQuery = "";
291
307
 
292
- statusText.content = "✓ Added successfully";
293
- statusText.fg = "#10b981";
308
+ statusText.content = "";
309
+ }
310
+
311
+ function enterConfirmMode() {
312
+ isConfirming = true;
313
+ confirmOptionIndex = 2; // Start on confirm button for quick add
314
+ repoInput.blur();
315
+ updateDetails();
316
+ updateFooter();
317
+ }
318
+
319
+ function exitConfirmMode() {
320
+ isConfirming = false;
321
+ confirmOptionIndex = 0;
322
+ repoInput.focus();
323
+ updateDetails();
324
+ updateFooter();
325
+ }
294
326
 
295
- setTimeout(() => {
296
- if (statusText.content?.toString() === "✓ Added successfully") {
297
- statusText.content = "";
327
+ function toggleCurrentOption() {
328
+ if (confirmOptionIndex === 0) {
329
+ // Toggle read-only
330
+ currentReadOnly = !currentReadOnly;
331
+ if (!currentReadOnly) {
332
+ currentLatest = false;
298
333
  }
299
- }, 2000);
334
+ } else if (confirmOptionIndex === 1) {
335
+ // Toggle latest
336
+ currentLatest = !currentLatest;
337
+ if (currentLatest) {
338
+ currentReadOnly = true;
339
+ }
340
+ } else if (confirmOptionIndex === 2) {
341
+ // Confirm button - add the repo
342
+ addCurrentRepo();
343
+ exitConfirmMode();
344
+ }
345
+ updateDetails();
346
+ }
347
+
348
+ function updateFooter() {
349
+ if (isConfirming) {
350
+ showFooter(renderer, content, {
351
+ navigate: true,
352
+ select: false,
353
+ back: true,
354
+ custom: ["r Read-only", "l Latest", "space Toggle", "enter Add"],
355
+ });
356
+ } else {
357
+ showFooter(renderer, content, {
358
+ navigate: true,
359
+ select: true,
360
+ back: true,
361
+ custom: repos.length > 0 ? ["enter (empty) Continue"] : [],
362
+ });
363
+ }
300
364
  }
301
365
 
302
366
  const handleKeypress = (key: KeyEvent) => {
303
- if (mode === "details") {
367
+ // Escape behavior differs based on mode
368
+ if (isEscape(key)) {
369
+ if (isConfirming) {
370
+ // Exit confirmation mode, go back to input
371
+ exitConfirmMode();
372
+ return;
373
+ }
374
+ cleanup();
375
+ resolve({ repos, cancelled: true });
376
+ return;
377
+ }
378
+
379
+ // Confirmation mode handling
380
+ if (isConfirming) {
381
+ // Toggle read-only with 'r' hotkey
304
382
  if (key.name === "r") {
305
- pendingReadOnly = !pendingReadOnly;
306
- if (!pendingReadOnly) {
307
- pendingLatest = false;
383
+ currentReadOnly = !currentReadOnly;
384
+ if (!currentReadOnly) {
385
+ currentLatest = false;
308
386
  }
309
387
  updateDetails();
310
- } else if (key.name === "l") {
311
- pendingLatest = !pendingLatest;
312
- if (pendingLatest) {
313
- pendingReadOnly = true;
388
+ return;
389
+ }
390
+
391
+ // Toggle latest with 'l' hotkey
392
+ if (key.name === "l") {
393
+ currentLatest = !currentLatest;
394
+ if (currentLatest) {
395
+ currentReadOnly = true;
314
396
  }
315
397
  updateDetails();
316
- } else if (key.name === "escape" || key.name === "backspace") {
317
- mode = "spec";
318
- pendingRepo = null;
319
- pendingReadOnly = false;
320
- pendingLatest = false;
321
- repoInput.focus();
398
+ return;
399
+ }
400
+
401
+ // Space to toggle current option
402
+ if (key.name === "space") {
403
+ toggleCurrentOption();
404
+ return;
405
+ }
406
+
407
+ // Enter to confirm/add
408
+ if (isEnter(key)) {
409
+ addCurrentRepo();
410
+ exitConfirmMode();
411
+ return;
412
+ }
413
+
414
+ // Arrow keys to navigate options
415
+ if (isArrowUp(key)) {
416
+ confirmOptionIndex = Math.max(0, confirmOptionIndex - 1);
322
417
  updateDetails();
323
- } else if (key.name === "return" || key.name === "enter") {
324
- confirmAddRepo();
418
+ return;
325
419
  }
326
- } else {
327
- // Spec input mode
328
- if (key.name === "escape") {
329
- cleanup();
330
- resolve({ repos, cancelled: true });
331
- } else if (key.name === "return" || key.name === "enter") {
332
- startRepoDetails();
333
- } else if (key.name === "tab") {
334
- const query = currentInput.trim();
335
- const matches = getMatches(query);
336
- if (matches.length === 0) return;
337
-
338
- const currentIndex = matches.findIndex((spec) => spec === currentInput);
339
- let nextIndex = 0;
340
-
341
- if (currentIndex !== -1) {
342
- nextIndex = (currentIndex + 1) % matches.length;
343
- } else if (lastMatchQuery === query.toLowerCase() && lastMatchIndex >= 0) {
344
- nextIndex = (lastMatchIndex + 1) % matches.length;
345
- }
346
420
 
347
- const match = matches[nextIndex];
348
- if (!match) return;
349
- repoInput.value = match;
350
- repoInput.cursorPosition = match.length;
351
- currentInput = match;
352
- lastMatchIndex = nextIndex;
353
- lastMatchQuery = query.toLowerCase();
354
- validateAndAddRepo(match.trim());
355
- updateMatches(currentInput, match);
356
- } else if (key.name === "up") {
357
- if (historySpecs.length === 0) return;
358
- historyIndex = Math.max(0, historyIndex - 1);
359
- const spec = historySpecs[historyIndex];
360
- if (spec) applyHistorySpec(spec);
361
- } else if (key.name === "down") {
362
- if (historySpecs.length === 0) return;
363
- historyIndex = Math.min(historySpecs.length - 1, historyIndex + 1);
364
- const spec = historySpecs[historyIndex];
365
- if (spec) applyHistorySpec(spec);
366
- } else if (key.name === "d" && key.ctrl) {
367
- // Ctrl+D to finish
421
+ if (isArrowDown(key)) {
422
+ confirmOptionIndex = Math.min(2, confirmOptionIndex + 1);
423
+ updateDetails();
424
+ return;
425
+ }
426
+
427
+ return;
428
+ }
429
+
430
+ // Normal input mode handling
431
+
432
+ // Enter to enter confirmation mode or continue
433
+ if (isEnter(key)) {
434
+ if (currentInput.trim() && currentValidRepo) {
435
+ enterConfirmMode();
436
+ } else if (!currentInput.trim() && repos.length > 0) {
437
+ // Empty input + repos added = continue
368
438
  cleanup();
369
439
  resolve({ repos, cancelled: false });
370
440
  }
441
+ return;
442
+ }
443
+
444
+ // Arrow keys for navigating matches
445
+ if (isArrowUp(key)) {
446
+ const matches = getMatchesForQuery(lastQuery);
447
+ if (matches.length === 0) return;
448
+
449
+ if (selectedMatchIndex < 0) {
450
+ // Start from last match
451
+ selectedMatchIndex = matches.length - 1;
452
+ } else {
453
+ // Move up
454
+ selectedMatchIndex = Math.max(0, selectedMatchIndex - 1);
455
+ }
456
+ selectMatch(selectedMatchIndex);
457
+ return;
458
+ }
459
+
460
+ if (isArrowDown(key)) {
461
+ const matches = getMatchesForQuery(lastQuery);
462
+ if (matches.length === 0) return;
463
+
464
+ if (selectedMatchIndex < 0) {
465
+ // Start from first match
466
+ selectedMatchIndex = 0;
467
+ } else {
468
+ // Move down
469
+ selectedMatchIndex = Math.min(matches.length - 1, selectedMatchIndex + 1);
470
+ }
471
+ selectMatch(selectedMatchIndex);
472
+ return;
371
473
  }
372
474
  };
373
475
 
374
476
  repoInput.on("input", (value: string) => {
375
477
  currentInput = value;
376
- historyIndex = historySpecs.length;
377
- lastMatchIndex = -1;
378
- lastMatchQuery = value.trim().toLowerCase();
478
+
479
+ // If we're navigating (programmatically setting value), don't reset selection
480
+ if (isNavigating) {
481
+ return;
482
+ }
483
+
484
+ // User is typing - reset selected match index and update query
485
+ selectedMatchIndex = -1;
486
+ lastQuery = value; // Update the query that generates matches
487
+
379
488
  if (value.trim()) {
380
- validateAndAddRepo(value.trim());
489
+ const repo = validateRepo(value.trim());
490
+ currentValidRepo = repo;
491
+ if (repo) {
492
+ currentReadOnly = false;
493
+ currentLatest = false;
494
+ }
381
495
  } else {
382
496
  statusText.content = "";
497
+ currentValidRepo = null;
383
498
  }
384
- updateMatches(value);
499
+ updateMatches(); // Update matches based on new lastQuery
500
+ updateDetails();
385
501
  });
386
502
 
387
503
  const cleanup = () => {
388
504
  renderer.keyInput.off("keypress", handleKeypress);
389
505
  repoInput.blur();
506
+ hideFooter(renderer);
390
507
  clearLayout(renderer);
391
508
  };
392
509
 
393
510
  renderer.keyInput.on("keypress", handleKeypress);
394
511
  updateDetails();
512
+ updateReposList();
513
+ updateFooter();
395
514
  });
396
515
  }