letmecook 0.0.13 → 0.0.14

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,12 @@ 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
30
31
 
31
32
  // Repository input
32
33
  const repoLabel = new TextRenderable(renderer, {
@@ -67,10 +68,10 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
67
68
  });
68
69
  content.add(matchesList);
69
70
 
70
- // Details form
71
+ // Inline toggles (always visible when repo is valid)
71
72
  const detailsLabel = new TextRenderable(renderer, {
72
73
  id: "details-label",
73
- content: "\nRepository details:",
74
+ content: "",
74
75
  fg: "#e2e8f0",
75
76
  marginTop: 1,
76
77
  });
@@ -78,7 +79,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
78
79
 
79
80
  const detailsReadOnly = new TextRenderable(renderer, {
80
81
  id: "details-readonly",
81
- content: " Read-only: No",
82
+ content: "",
82
83
  fg: "#94a3b8",
83
84
  marginTop: 0,
84
85
  });
@@ -86,7 +87,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
86
87
 
87
88
  const detailsLatest = new TextRenderable(renderer, {
88
89
  id: "details-latest",
89
- content: " Latest: No",
90
+ content: "",
90
91
  fg: "#94a3b8",
91
92
  marginTop: 0,
92
93
  });
@@ -98,10 +99,20 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
98
99
  repoInput.insertText(text);
99
100
  currentInput = repoInput.value;
100
101
  if (currentInput.trim()) {
101
- validateAndAddRepo(currentInput.trim());
102
+ const repo = validateRepo(currentInput.trim());
103
+ currentValidRepo = repo;
104
+ if (repo) {
105
+ currentReadOnly = false;
106
+ currentLatest = false;
107
+ }
102
108
  } else {
103
109
  statusText.content = "";
110
+ currentValidRepo = null;
104
111
  }
112
+ selectedMatchIndex = -1;
113
+ lastQuery = currentInput;
114
+ updateMatches();
115
+ updateDetails();
105
116
  event.preventDefault();
106
117
  };
107
118
 
@@ -114,7 +125,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
114
125
  });
115
126
  content.add(statusText);
116
127
 
117
- // Repos list
128
+ // Repos list with counter
118
129
  const reposLabel = new TextRenderable(renderer, {
119
130
  id: "repos-label",
120
131
  content: "\nAdded repositories:",
@@ -131,15 +142,6 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
131
142
  });
132
143
  content.add(reposList);
133
144
 
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
145
  repoInput.focus();
144
146
 
145
147
  function updateReposList() {
@@ -159,43 +161,37 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
159
161
  }
160
162
 
161
163
  function updateDetails() {
162
- if (mode === "details" && pendingRepo) {
164
+ if (currentValidRepo) {
163
165
  detailsLabel.content = "\nRepository details:";
164
166
  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";
167
+ detailsReadOnly.content = ` Read-only: ${currentReadOnly ? "Yes [r]" : "No [r]"}`;
168
+ detailsReadOnly.fg = currentReadOnly ? "#f59e0b" : "#94a3b8";
169
+ detailsLatest.content = ` Latest: ${currentLatest ? "Yes [l]" : "No [l]"}`;
170
+ detailsLatest.fg = currentLatest ? "#22d3ee" : "#94a3b8";
171
171
  } 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";
172
+ detailsLabel.content = "";
173
+ detailsReadOnly.content = "";
174
+ detailsLatest.content = "";
180
175
  }
181
176
  }
182
177
 
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
- }
178
+ function getMatchesForQuery(query: string): string[] {
179
+ const trimmed = query.trim();
180
+ if (!trimmed) return [];
190
181
 
191
- const lowerQuery = query.toLowerCase();
192
- const matches = historySpecs
182
+ const lowerQuery = trimmed.toLowerCase();
183
+ return historySpecs
193
184
  .filter((spec) => spec.toLowerCase().startsWith(lowerQuery))
194
185
  .toSorted((a, b) => {
195
186
  if (a.length !== b.length) return a.length - b.length;
196
187
  return a.localeCompare(b);
197
188
  })
198
189
  .slice(0, maxMatches);
190
+ }
191
+
192
+ function updateMatches() {
193
+ const query = lastQuery; // Use the last query, not currentInput (which may be a selected match)
194
+ const matches = getMatchesForQuery(query);
199
195
 
200
196
  if (matches.length === 0) {
201
197
  matchesList.content = "(no matches)";
@@ -205,192 +201,200 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
205
201
 
206
202
  matchesList.content = matches
207
203
  .map((spec, index) => {
208
- const isSelected = selectedSpec ? spec === selectedSpec : index === 0;
204
+ const isSelected = selectedMatchIndex === index;
209
205
  return `${isSelected ? "▶" : " "} ${spec}`;
210
206
  })
211
207
  .join("\n");
212
208
  matchesList.fg = "#94a3b8";
213
209
  }
214
210
 
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 {
211
+ function validateRepo(spec: string): RepoSpec | null {
229
212
  try {
230
- parseRepoSpec(spec);
213
+ const repo = parseRepoSpec(spec);
231
214
  statusText.content = "✓ Valid format";
232
215
  statusText.fg = "#10b981";
233
- return true;
216
+ return repo;
234
217
  } catch (error) {
235
218
  statusText.content = `✗ ${error instanceof Error ? error.message : "Invalid format"}`;
236
219
  statusText.fg = "#ef4444";
237
- return false;
220
+ return null;
238
221
  }
239
222
  }
240
223
 
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 = "";
224
+ function selectMatch(matchIndex: number) {
225
+ const matches = getMatchesForQuery(lastQuery);
226
+ if (matchIndex < 0 || matchIndex >= matches.length) return;
227
+
228
+ const selectedMatch = matches[matchIndex];
229
+ if (!selectedMatch) return;
230
+
231
+ selectedMatchIndex = matchIndex;
232
+ isNavigating = true; // Set flag to prevent input handler from resetting
233
+
234
+ // Update input with selected match
235
+ repoInput.value = selectedMatch;
236
+ repoInput.cursorPosition = selectedMatch.length; // Set cursor to end
237
+ currentInput = selectedMatch;
238
+
239
+ // Validate and update details
240
+ const repo = validateRepo(selectedMatch.trim());
241
+ currentValidRepo = repo;
242
+ if (repo) {
243
+ currentReadOnly = false;
244
+ currentLatest = false;
248
245
  }
249
- updateMatches(currentInput);
246
+
247
+ updateMatches(); // Refresh display with new selection (matches stay the same, just highlight changes)
248
+ updateDetails();
249
+
250
+ isNavigating = false; // Reset flag
250
251
  }
251
252
 
252
- function startRepoDetails() {
253
- const spec = currentInput.trim();
254
- if (!spec) return;
255
-
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
- }
253
+ function addCurrentRepo() {
254
+ if (!currentValidRepo) return;
263
255
 
264
- pendingRepo = parseRepoSpec(spec);
265
- pendingReadOnly = false;
266
- pendingLatest = false;
267
- mode = "details";
268
- repoInput.blur();
269
- updateDetails();
256
+ const spec = currentInput.trim();
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;
270
262
  }
271
- }
272
263
 
273
- function confirmAddRepo() {
274
- if (!pendingRepo) return;
275
- pendingRepo.readOnly = pendingReadOnly;
276
- pendingRepo.latest = pendingLatest;
277
- repos.push(pendingRepo);
264
+ const repoToAdd = { ...currentValidRepo };
265
+ repoToAdd.readOnly = currentReadOnly;
266
+ repoToAdd.latest = currentLatest;
267
+ repos.push(repoToAdd);
278
268
 
279
- pendingRepo = null;
280
- pendingReadOnly = false;
281
- pendingLatest = false;
282
- mode = "spec";
283
269
  currentInput = "";
284
270
  repoInput.value = "";
285
- repoInput.focus();
271
+ currentValidRepo = null;
272
+ currentReadOnly = false;
273
+ currentLatest = false;
286
274
  updateReposList();
287
- updateMatches("");
275
+ lastQuery = "";
276
+ selectedMatchIndex = -1;
277
+ updateMatches();
288
278
  updateDetails();
289
- lastMatchIndex = -1;
290
- lastMatchQuery = "";
291
-
292
- statusText.content = "✓ Added successfully";
293
- statusText.fg = "#10b981";
294
279
 
295
- setTimeout(() => {
296
- if (statusText.content?.toString() === "✓ Added successfully") {
297
- statusText.content = "";
298
- }
299
- }, 2000);
280
+ statusText.content = "";
300
281
  }
301
282
 
302
283
  const handleKeypress = (key: KeyEvent) => {
303
- if (mode === "details") {
304
- if (key.name === "r") {
305
- pendingReadOnly = !pendingReadOnly;
306
- if (!pendingReadOnly) {
307
- pendingLatest = false;
308
- }
309
- updateDetails();
310
- } else if (key.name === "l") {
311
- pendingLatest = !pendingLatest;
312
- if (pendingLatest) {
313
- pendingReadOnly = true;
314
- }
315
- 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();
322
- updateDetails();
323
- } else if (key.name === "return" || key.name === "enter") {
324
- confirmAddRepo();
284
+ if (isEscape(key)) {
285
+ cleanup();
286
+ resolve({ repos, cancelled: true });
287
+ return;
288
+ }
289
+
290
+ // Toggle read-only
291
+ if (key.name === "r" && currentValidRepo) {
292
+ currentReadOnly = !currentReadOnly;
293
+ if (!currentReadOnly) {
294
+ currentLatest = false;
325
295
  }
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
-
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
296
+ updateDetails();
297
+ return;
298
+ }
299
+
300
+ // Toggle latest
301
+ if (key.name === "l" && currentValidRepo) {
302
+ currentLatest = !currentLatest;
303
+ if (currentLatest) {
304
+ currentReadOnly = true;
305
+ }
306
+ updateDetails();
307
+ return;
308
+ }
309
+
310
+ // Enter to add repo or continue
311
+ if (isEnter(key)) {
312
+ if (currentInput.trim() && currentValidRepo) {
313
+ addCurrentRepo();
314
+ } else if (!currentInput.trim() && repos.length > 0) {
315
+ // Empty input + repos added = continue
368
316
  cleanup();
369
317
  resolve({ repos, cancelled: false });
370
318
  }
319
+ return;
320
+ }
321
+
322
+ // Arrow keys for navigating matches
323
+ if (isArrowUp(key)) {
324
+ const matches = getMatchesForQuery(lastQuery);
325
+ if (matches.length === 0) return;
326
+
327
+ if (selectedMatchIndex < 0) {
328
+ // Start from last match
329
+ selectedMatchIndex = matches.length - 1;
330
+ } else {
331
+ // Move up
332
+ selectedMatchIndex = Math.max(0, selectedMatchIndex - 1);
333
+ }
334
+ selectMatch(selectedMatchIndex);
335
+ return;
336
+ }
337
+
338
+ if (isArrowDown(key)) {
339
+ const matches = getMatchesForQuery(lastQuery);
340
+ if (matches.length === 0) return;
341
+
342
+ if (selectedMatchIndex < 0) {
343
+ // Start from first match
344
+ selectedMatchIndex = 0;
345
+ } else {
346
+ // Move down
347
+ selectedMatchIndex = Math.min(matches.length - 1, selectedMatchIndex + 1);
348
+ }
349
+ selectMatch(selectedMatchIndex);
350
+ return;
371
351
  }
372
352
  };
373
353
 
374
354
  repoInput.on("input", (value: string) => {
375
355
  currentInput = value;
376
- historyIndex = historySpecs.length;
377
- lastMatchIndex = -1;
378
- lastMatchQuery = value.trim().toLowerCase();
356
+
357
+ // If we're navigating (programmatically setting value), don't reset selection
358
+ if (isNavigating) {
359
+ return;
360
+ }
361
+
362
+ // User is typing - reset selected match index and update query
363
+ selectedMatchIndex = -1;
364
+ lastQuery = value; // Update the query that generates matches
365
+
379
366
  if (value.trim()) {
380
- validateAndAddRepo(value.trim());
367
+ const repo = validateRepo(value.trim());
368
+ currentValidRepo = repo;
369
+ if (repo) {
370
+ currentReadOnly = false;
371
+ currentLatest = false;
372
+ }
381
373
  } else {
382
374
  statusText.content = "";
375
+ currentValidRepo = null;
383
376
  }
384
- updateMatches(value);
377
+ updateMatches(); // Update matches based on new lastQuery
378
+ updateDetails();
385
379
  });
386
380
 
387
381
  const cleanup = () => {
388
382
  renderer.keyInput.off("keypress", handleKeypress);
389
383
  repoInput.blur();
384
+ hideFooter(renderer);
390
385
  clearLayout(renderer);
391
386
  };
392
387
 
388
+ // Show footer with context-aware actions
389
+ showFooter(renderer, content, {
390
+ navigate: true,
391
+ select: true,
392
+ back: true,
393
+ custom: currentValidRepo ? ["r Toggle RO", "l Toggle Latest"] : ["↑↓ Navigate matches"],
394
+ });
395
+
393
396
  renderer.keyInput.on("keypress", handleKeypress);
394
397
  updateDetails();
398
+ updateReposList();
395
399
  });
396
400
  }