@solaqua/gji 0.7.1 → 0.8.0

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.
@@ -0,0 +1,924 @@
1
+ import { env, platform, stdin, stdout } from "node:process";
2
+ import { isCancel, Prompt } from "@clack/core";
3
+ import { loadHistory } from "./history.js";
4
+ import { readWorktreeInfos, } from "./worktree-info.js";
5
+ export async function buildWorktreePromptEntries(sources) {
6
+ const [history, infos] = await Promise.all([
7
+ loadHistory(),
8
+ readWorktreeInfos(sources.map((source) => source.worktree)),
9
+ ]);
10
+ const historyByPath = new Map(history.map((entry) => [entry.path, entry]));
11
+ const entries = sources.map((source, index) => buildWorktreePromptEntry(source, infos[index], historyByPath.get(source.worktree.path)?.timestamp ?? null, Date.now()));
12
+ return entries
13
+ .sort(comparePromptEntries)
14
+ .map(({ lastActivityTimestamp: _lastActivityTimestamp, ...entry }) => entry);
15
+ }
16
+ export function resolveWorktreeQuery(sources, query) {
17
+ const normalizedQuery = normalizeQuery(query);
18
+ if (normalizedQuery === null)
19
+ return null;
20
+ const matches = findWorktreePromptSourceMatches(sources, normalizedQuery);
21
+ if (isAmbiguousRepoOnlyQuery(matches, normalizedQuery))
22
+ return null;
23
+ return matches[0]?.source ?? null;
24
+ }
25
+ function findWorktreePromptSourceMatches(sources, normalizedQuery) {
26
+ return sources
27
+ .flatMap((source) => {
28
+ const matchScore = scoreWorktreeMatch({
29
+ ...source.worktree,
30
+ repoName: source.repoName,
31
+ }, normalizedQuery);
32
+ return matchScore === null ? [] : [{ matchScore, source }];
33
+ })
34
+ .sort(compareQueryMatches);
35
+ }
36
+ function isAmbiguousRepoOnlyQuery(matches, query) {
37
+ if (matches[0]?.matchScore === 1000)
38
+ return false;
39
+ return (matches.filter((match) => match.source.repoName.toLowerCase() === query)
40
+ .length > 1);
41
+ }
42
+ export async function promptForSingleWorktree(message, worktrees, io = {}) {
43
+ const choice = await runSearchablePrompt({
44
+ entries: worktrees.map(buildSearchableWorktreeEntry),
45
+ input: io.input,
46
+ message,
47
+ multiple: false,
48
+ output: io.output,
49
+ });
50
+ return typeof choice === "string" ? choice : null;
51
+ }
52
+ export async function promptForMultipleWorktrees(message, worktrees, io = {}) {
53
+ const choice = await runSearchablePrompt({
54
+ entries: buildGroupedSearchableEntries(worktrees),
55
+ input: io.input,
56
+ message,
57
+ multiple: true,
58
+ output: io.output,
59
+ });
60
+ return Array.isArray(choice) ? choice : null;
61
+ }
62
+ function compareQueryMatches(a, b) {
63
+ if (a.matchScore !== b.matchScore) {
64
+ return b.matchScore - a.matchScore;
65
+ }
66
+ if (a.source.worktree.isCurrent && !b.source.worktree.isCurrent)
67
+ return -1;
68
+ if (!a.source.worktree.isCurrent && b.source.worktree.isCurrent)
69
+ return 1;
70
+ return (a.source.repoName.localeCompare(b.source.repoName) ||
71
+ (a.source.worktree.branch ?? "").localeCompare(b.source.worktree.branch ?? "") ||
72
+ a.source.worktree.path.localeCompare(b.source.worktree.path));
73
+ }
74
+ function groupPromptEntries(worktrees) {
75
+ const groups = new Map();
76
+ for (const worktree of worktrees) {
77
+ const group = worktree.group === "recent" ? "Recent worktrees" : "Other worktrees";
78
+ const groupWorktrees = groups.get(group);
79
+ if (groupWorktrees === undefined) {
80
+ groups.set(group, [worktree]);
81
+ }
82
+ else {
83
+ groupWorktrees.push(worktree);
84
+ }
85
+ }
86
+ return [...groups.entries()];
87
+ }
88
+ function buildGroupedSearchableEntries(worktrees) {
89
+ const entries = [];
90
+ for (const [group, groupWorktrees] of groupPromptEntries(worktrees)) {
91
+ entries.push({
92
+ label: group,
93
+ searchText: group.toLowerCase(),
94
+ selectable: false,
95
+ });
96
+ for (const worktree of groupWorktrees) {
97
+ entries.push(buildSearchableWorktreeEntry(worktree));
98
+ }
99
+ }
100
+ return entries;
101
+ }
102
+ function buildSearchableWorktreeEntry(worktree) {
103
+ const metadata = worktree.metadata ?? null;
104
+ const branch = worktree.branch ?? "(detached)";
105
+ return {
106
+ detail: [worktree.repoName, branch, metadata, worktree.path]
107
+ .filter((part) => part !== null && part.length > 0)
108
+ .join(" · "),
109
+ label: worktree.label,
110
+ searchText: buildPromptSearchText(worktree),
111
+ value: worktree.path,
112
+ worktree: {
113
+ branch,
114
+ metadata,
115
+ path: worktree.path,
116
+ repoName: worktree.repoName,
117
+ },
118
+ };
119
+ }
120
+ async function runSearchablePrompt(options) {
121
+ const input = options.input ?? stdin;
122
+ const output = options.output ?? stdout;
123
+ if (!input.isTTY) {
124
+ return null;
125
+ }
126
+ const prompt = new SearchablePrompt(options, input, output);
127
+ const value = await prompt.run();
128
+ return isCancel(value) ? null : value;
129
+ }
130
+ class SearchablePrompt {
131
+ options;
132
+ output;
133
+ cursor = 0;
134
+ query = "";
135
+ searchActive = false;
136
+ selected = new Set();
137
+ prompt;
138
+ constructor(options, input, output) {
139
+ this.options = options;
140
+ this.output = output;
141
+ this.cursor = this.firstSelectableIndex();
142
+ const owner = this;
143
+ this.prompt = new WorktreeCorePrompt(this, {
144
+ input,
145
+ output,
146
+ render: function renderWorktreePrompt() {
147
+ return owner.render(this.state, this.error);
148
+ },
149
+ });
150
+ this.syncValue();
151
+ }
152
+ run() {
153
+ return this.prompt.prompt();
154
+ }
155
+ handleKeypress(prompt, character, key) {
156
+ if (prompt.state === "error") {
157
+ prompt.state = "active";
158
+ prompt.error = "";
159
+ }
160
+ const action = resolvePromptAction(character, key);
161
+ if (action === "cancel") {
162
+ this.cancel(prompt);
163
+ return;
164
+ }
165
+ if (action === "escape") {
166
+ this.handleEscapeKey(prompt);
167
+ return;
168
+ }
169
+ if (action === "enter") {
170
+ this.submit(prompt);
171
+ return;
172
+ }
173
+ if (this.searchActive && this.handleSearchKey(character, key?.name)) {
174
+ this.syncValue();
175
+ renderPrompt(prompt);
176
+ return;
177
+ }
178
+ if (character === "/" && !this.searchActive) {
179
+ this.searchActive = true;
180
+ this.query = "";
181
+ this.cursor = this.firstSelectableIndex();
182
+ this.syncValue();
183
+ renderPrompt(prompt);
184
+ return;
185
+ }
186
+ this.handleNavigationKey(character, action);
187
+ this.syncValue();
188
+ renderPrompt(prompt);
189
+ }
190
+ handleEscapeKey(prompt) {
191
+ if (this.searchActive) {
192
+ this.searchActive = false;
193
+ this.query = "";
194
+ this.cursor = this.firstSelectableIndex();
195
+ this.syncValue();
196
+ renderPrompt(prompt);
197
+ return;
198
+ }
199
+ this.cancel(prompt);
200
+ }
201
+ cancel(prompt) {
202
+ prompt.state = "cancel";
203
+ renderPrompt(prompt);
204
+ closePrompt(prompt);
205
+ }
206
+ handleSearchKey(character, keyName) {
207
+ if (keyName === "space" || character === " ") {
208
+ return false;
209
+ }
210
+ if (keyName === "backspace" || keyName === "delete") {
211
+ this.query = this.query.slice(0, -1);
212
+ this.cursor = this.firstSelectableIndex();
213
+ return true;
214
+ }
215
+ if (isPrintableSearchCharacter(character)) {
216
+ this.query += character;
217
+ this.cursor = this.firstSelectableIndex();
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ handleNavigationKey(character, action) {
223
+ switch (action) {
224
+ case "up":
225
+ this.moveCursor(-1);
226
+ return;
227
+ case "down":
228
+ this.moveCursor(1);
229
+ return;
230
+ case "space":
231
+ this.toggleSelection();
232
+ return;
233
+ }
234
+ switch (character) {
235
+ case "k":
236
+ this.moveCursor(-1);
237
+ return;
238
+ case "j":
239
+ this.moveCursor(1);
240
+ return;
241
+ case " ":
242
+ this.toggleSelection();
243
+ return;
244
+ }
245
+ }
246
+ moveCursor(direction) {
247
+ const entries = this.visibleEntries();
248
+ if (entries.length === 0)
249
+ return;
250
+ let nextCursor = this.cursor;
251
+ for (let index = 0; index < entries.length; index += 1) {
252
+ nextCursor = wrapIndex(nextCursor + direction, entries.length);
253
+ if (isSelectableEntry(entries[nextCursor])) {
254
+ this.cursor = nextCursor;
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ submit(prompt) {
260
+ const entry = this.visibleEntries()[this.cursor];
261
+ if (!this.options.multiple) {
262
+ prompt.value = isSelectableEntry(entry) ? entry.value : null;
263
+ prompt.state = "submit";
264
+ renderPrompt(prompt);
265
+ closePrompt(prompt);
266
+ return;
267
+ }
268
+ if (this.selected.size === 0) {
269
+ prompt.error = "Please select at least one option.";
270
+ prompt.state = "error";
271
+ renderPrompt(prompt);
272
+ return;
273
+ }
274
+ prompt.value = [...this.selected];
275
+ prompt.state = "submit";
276
+ renderPrompt(prompt);
277
+ closePrompt(prompt);
278
+ }
279
+ toggleSelection() {
280
+ if (!this.options.multiple)
281
+ return;
282
+ const entry = this.visibleEntries()[this.cursor];
283
+ if (!isSelectableEntry(entry))
284
+ return;
285
+ if (this.selected.has(entry.value)) {
286
+ this.selected.delete(entry.value);
287
+ return;
288
+ }
289
+ this.selected.add(entry.value);
290
+ }
291
+ render(state, error) {
292
+ const entries = this.visibleEntries();
293
+ const visibleEntries = windowPromptEntries(entries, this.cursor, this.maxItems());
294
+ const glyphs = promptGlyphs();
295
+ const colors = promptColors();
296
+ const frame = promptFrameColor(state, colors);
297
+ const lines = [
298
+ colors.bar(glyphs.bar),
299
+ this.renderTitle(state, glyphs, colors),
300
+ `${frame(glyphs.bar)} ${this.renderSearchHint(colors)}`,
301
+ frame(glyphs.bar),
302
+ ];
303
+ if (state === "error" && error.length > 0) {
304
+ lines.push(`${colors.errorBar(glyphs.bar)} ${colors.error(error)}`);
305
+ lines.push(frame(glyphs.bar));
306
+ }
307
+ lines.push(...this.renderEntries(entries, visibleEntries, glyphs, colors, frame));
308
+ lines.push(frame(glyphs.bar));
309
+ const preview = this.activePreview(entries, colors);
310
+ if (preview !== null) {
311
+ lines.push(`${frame(glyphs.bar)} ${preview}`);
312
+ lines.push(frame(glyphs.bar));
313
+ }
314
+ const visibleWorktreeCount = entries.filter(isSelectableEntry).length;
315
+ const footer = this.footerText(visibleWorktreeCount, colors);
316
+ if (footer.length > 0) {
317
+ lines.push(`${frame(glyphs.corner)} ${footer}`);
318
+ }
319
+ return `${lines.join("\n")}\n`;
320
+ }
321
+ renderTitle(state, glyphs, colors) {
322
+ const symbol = colors.symbol(promptSymbol(state, glyphs), state);
323
+ const maxTitleLength = Math.max(8, this.columns() - 3);
324
+ if (!this.searchActive) {
325
+ return `${symbol} ${middleEllipsize(this.options.message, maxTitleLength)}`;
326
+ }
327
+ const search = ` /${this.query}`;
328
+ const maxSearchLength = Math.min(terminalWidth(search), Math.floor(maxTitleLength / 2));
329
+ const message = middleEllipsize(this.options.message, Math.max(1, maxTitleLength - maxSearchLength));
330
+ const visibleSearch = middleEllipsize(search, Math.max(1, maxTitleLength - terminalWidth(message)));
331
+ return `${symbol} ${message}${colors.search(visibleSearch)}`;
332
+ }
333
+ renderSearchHint(colors) {
334
+ if (this.searchActive) {
335
+ return `${colors.hint("type to filter")} ${colors.hint("·")} ${colors.key("esc")} ${colors.hint("clears")}`;
336
+ }
337
+ return `${colors.hint("press ")}${colors.key("/")}${colors.hint(" to search")}`;
338
+ }
339
+ renderEntries(entries, visibleEntries, glyphs, colors, frame) {
340
+ if (entries.length === 0) {
341
+ return [`${frame(glyphs.bar)} ${colors.hint("No matching worktrees")}`];
342
+ }
343
+ return visibleEntries.map((entry) => entry === "ellipsis"
344
+ ? `${frame(glyphs.bar)} ${colors.hint("...")}`
345
+ : this.renderEntry(entries, entry, glyphs, colors, frame));
346
+ }
347
+ renderEntry(entries, entry, glyphs, colors, frame) {
348
+ const active = entries[this.cursor] === entry;
349
+ const selected = isSelectableEntry(entry) && this.selected.has(entry.value);
350
+ const prefix = this.entryPrefix(entry, active, selected, glyphs);
351
+ const label = this.entryLabel(entry, prefix);
352
+ const line = isSelectableEntry(entry)
353
+ ? active
354
+ ? label
355
+ : colors.hint(label)
356
+ : colors.hint(label);
357
+ const marker = selected
358
+ ? colors.selected(prefix)
359
+ : active
360
+ ? this.options.multiple
361
+ ? colors.option(prefix)
362
+ : colors.selected(prefix)
363
+ : colors.hint(prefix);
364
+ return `${frame(glyphs.bar)} ${marker} ${line}`;
365
+ }
366
+ activePreview(entries, colors) {
367
+ const entry = entries[this.cursor];
368
+ if (!isSelectableEntry(entry) || entry.detail === undefined)
369
+ return null;
370
+ const label = "current";
371
+ const separator = " ";
372
+ const width = this.previewWidth();
373
+ const prefixWidth = terminalWidth(label) + terminalWidth(separator);
374
+ if (width <= prefixWidth)
375
+ return colors.key(middleEllipsize(label, width));
376
+ const detail = middleEllipsize(entry.detail, width - prefixWidth);
377
+ return `${colors.key(label)}${colors.hint(separator)}${colors.hint(detail)}`;
378
+ }
379
+ entryLabel(entry, prefix) {
380
+ const width = this.labelWidth(prefix);
381
+ if (entry.worktree === undefined) {
382
+ return middleEllipsize(entry.label, width);
383
+ }
384
+ return fitPromptPieces([
385
+ {
386
+ ellipsize: middleEllipsize,
387
+ max: 18,
388
+ min: 6,
389
+ value: entry.worktree.repoName,
390
+ },
391
+ {
392
+ ellipsize: middleEllipsize,
393
+ max: 32,
394
+ min: 8,
395
+ value: entry.worktree.branch,
396
+ },
397
+ ...(entry.worktree.metadata === null
398
+ ? []
399
+ : [
400
+ {
401
+ ellipsize: middleEllipsize,
402
+ max: 30,
403
+ min: 10,
404
+ value: entry.worktree.metadata,
405
+ },
406
+ ]),
407
+ {
408
+ ellipsize: startEllipsize,
409
+ max: 36,
410
+ min: 12,
411
+ value: entry.worktree.path,
412
+ },
413
+ ], width);
414
+ }
415
+ labelWidth(prefix) {
416
+ return Math.max(8, this.columns() - terminalWidth(prefix) - 4);
417
+ }
418
+ previewWidth() {
419
+ return Math.max(8, this.columns() - 3);
420
+ }
421
+ entryPrefix(entry, active, selected, glyphs) {
422
+ if (!isSelectableEntry(entry)) {
423
+ return active ? glyphs.active : " ";
424
+ }
425
+ if (!this.options.multiple) {
426
+ return active ? glyphs.active : glyphs.inactive;
427
+ }
428
+ if (active && selected)
429
+ return glyphs.checked;
430
+ if (active)
431
+ return glyphs.uncheckedActive;
432
+ return selected ? glyphs.checked : glyphs.unchecked;
433
+ }
434
+ footerText(visibleCount, colors) {
435
+ if (!this.options.multiple)
436
+ return "";
437
+ return [
438
+ `${colors.key("space")} ${colors.hint("select")}`,
439
+ `${colors.selected(String(this.selected.size))} ${colors.hint("selected")}`,
440
+ colors.hint(`${visibleCount} shown`),
441
+ ].join(colors.hint(" · "));
442
+ }
443
+ firstSelectableIndex() {
444
+ const index = this.visibleEntries().findIndex(isSelectableEntry);
445
+ return index === -1 ? 0 : index;
446
+ }
447
+ visibleEntries() {
448
+ const query = normalizeQuery(this.query);
449
+ if (query === null) {
450
+ return this.options.entries;
451
+ }
452
+ return filterSearchablePromptEntries(this.options.entries, query);
453
+ }
454
+ maxItems() {
455
+ const rows = this.output.rows ?? 16;
456
+ return Math.max(5, Math.min(12, rows - 8));
457
+ }
458
+ columns() {
459
+ return Math.max(20, this.output.columns ?? 80);
460
+ }
461
+ syncValue() {
462
+ if (this.options.multiple) {
463
+ this.prompt.value = [...this.selected];
464
+ return;
465
+ }
466
+ const entry = this.visibleEntries()[this.cursor];
467
+ this.prompt.value = isSelectableEntry(entry) ? entry.value : null;
468
+ }
469
+ }
470
+ class WorktreeCorePrompt extends Prompt {
471
+ worktreePrompt;
472
+ constructor(worktreePrompt, options) {
473
+ super(options, false);
474
+ this.worktreePrompt = worktreePrompt;
475
+ }
476
+ }
477
+ /*
478
+ * @clack/core owns raw mode, frame clearing, and cursor restoration, but it
479
+ * does not expose a public pre-cancel keypress hook. Keep this private adapter
480
+ * small so slash-search can clear on Esc before Clack maps Esc to cancel.
481
+ */
482
+ Object.defineProperty(WorktreeCorePrompt.prototype, "onKeypress", {
483
+ value: onWorktreeKeypress,
484
+ writable: true,
485
+ });
486
+ function renderPrompt(prompt) {
487
+ prompt.render();
488
+ }
489
+ function closePrompt(prompt) {
490
+ prompt.close();
491
+ }
492
+ function onWorktreeKeypress(character, key) {
493
+ this.worktreePrompt.handleKeypress(this, character, key);
494
+ }
495
+ function resolvePromptAction(character, key) {
496
+ if ((key?.ctrl && key.name === "c") || character === "\u0003") {
497
+ return "cancel";
498
+ }
499
+ switch (key?.name) {
500
+ case "escape":
501
+ return "escape";
502
+ case "return":
503
+ return "enter";
504
+ case "space":
505
+ return "space";
506
+ case "up":
507
+ case "left":
508
+ return "up";
509
+ case "down":
510
+ case "right":
511
+ return "down";
512
+ }
513
+ switch (character) {
514
+ case " ":
515
+ return "space";
516
+ case "k":
517
+ case "h":
518
+ return "up";
519
+ case "j":
520
+ case "l":
521
+ return "down";
522
+ }
523
+ return undefined;
524
+ }
525
+ function promptGlyphs() {
526
+ return supportsUnicode()
527
+ ? {
528
+ active: "●",
529
+ bar: "│",
530
+ checked: "◼",
531
+ corner: "└",
532
+ inactive: "○",
533
+ pending: "◆",
534
+ submitted: "◇",
535
+ unchecked: "◻",
536
+ uncheckedActive: "◻",
537
+ }
538
+ : {
539
+ active: ">",
540
+ bar: "|",
541
+ checked: "[x]",
542
+ corner: "-",
543
+ inactive: " ",
544
+ pending: "*",
545
+ submitted: "o",
546
+ unchecked: "[ ]",
547
+ uncheckedActive: "[ ]",
548
+ };
549
+ }
550
+ function promptColors() {
551
+ return {
552
+ activeBar: cyan,
553
+ bar: gray,
554
+ error: yellow,
555
+ errorBar: yellow,
556
+ hint: dim,
557
+ key: cyan,
558
+ option: cyan,
559
+ search: cyan,
560
+ selected: green,
561
+ symbol: (value, state) => {
562
+ if (state === "submit")
563
+ return green(value);
564
+ if (state === "error")
565
+ return yellow(value);
566
+ if (state === "cancel")
567
+ return red(value);
568
+ return cyan(value);
569
+ },
570
+ };
571
+ }
572
+ function promptFrameColor(state, colors) {
573
+ if (state === "error")
574
+ return colors.errorBar;
575
+ if (state === "active" || state === "initial")
576
+ return colors.activeBar;
577
+ return colors.bar;
578
+ }
579
+ function promptSymbol(state, glyphs) {
580
+ return state === "submit" ? glyphs.submitted : glyphs.pending;
581
+ }
582
+ function cyan(value) {
583
+ return color("\u001b[36m", "\u001b[39m", value);
584
+ }
585
+ function dim(value) {
586
+ return color("\u001b[2m", "\u001b[22m", value);
587
+ }
588
+ function gray(value) {
589
+ return color("\u001b[90m", "\u001b[39m", value);
590
+ }
591
+ function green(value) {
592
+ return color("\u001b[32m", "\u001b[39m", value);
593
+ }
594
+ function red(value) {
595
+ return color("\u001b[31m", "\u001b[39m", value);
596
+ }
597
+ function yellow(value) {
598
+ return color("\u001b[33m", "\u001b[39m", value);
599
+ }
600
+ function color(open, close, value) {
601
+ if (env.NO_COLOR !== undefined)
602
+ return value;
603
+ return `${open}${value}${close}`;
604
+ }
605
+ function supportsUnicode() {
606
+ if (platform !== "win32") {
607
+ return env.TERM !== "linux";
608
+ }
609
+ return Boolean(env.CI ||
610
+ env.WT_SESSION ||
611
+ env.TERMINUS_SUBLIME ||
612
+ env.ConEmuTask === "{cmd::Cmder}" ||
613
+ env.TERM_PROGRAM === "Terminus-Sublime" ||
614
+ env.TERM_PROGRAM === "vscode" ||
615
+ env.TERM === "xterm-256color" ||
616
+ env.TERM === "alacritty" ||
617
+ env.TERMINAL_EMULATOR === "JetBrains-JediTerm");
618
+ }
619
+ function isPrintableSearchCharacter(character) {
620
+ return (typeof character === "string" &&
621
+ character.length === 1 &&
622
+ character >= " " &&
623
+ character !== "\u007f");
624
+ }
625
+ function wrapIndex(index, length) {
626
+ return (index + length) % length;
627
+ }
628
+ function windowPromptEntries(entries, cursor, maxItems) {
629
+ if (entries.length <= maxItems) {
630
+ return entries;
631
+ }
632
+ const activeIndex = Math.min(Math.max(cursor, 0), entries.length - 1);
633
+ const start = Math.max(0, Math.min(activeIndex - 2, entries.length - maxItems));
634
+ const window = entries.slice(start, start + maxItems);
635
+ if (start > 0) {
636
+ window[0] = "ellipsis";
637
+ }
638
+ if (start + maxItems < entries.length) {
639
+ window[window.length - 1] = "ellipsis";
640
+ }
641
+ return window;
642
+ }
643
+ function fitPromptPieces(pieces, width) {
644
+ const separator = " · ";
645
+ let visiblePieces = pieces.filter((piece) => piece.value.length > 0);
646
+ let available = width - terminalWidth(separator) * Math.max(0, visiblePieces.length - 1);
647
+ while (visiblePieces.length > 1 &&
648
+ available < minimumPieceLength(visiblePieces)) {
649
+ const removableIndex = visiblePieces.length === 4 ? 2 : visiblePieces.length - 2;
650
+ visiblePieces = visiblePieces.filter((_, index) => index !== removableIndex);
651
+ available =
652
+ width - terminalWidth(separator) * Math.max(0, visiblePieces.length - 1);
653
+ }
654
+ if (available <= 0 || available < minimumPieceLength(visiblePieces)) {
655
+ return middleEllipsize(visiblePieces.map((piece) => piece.value).join(separator), width);
656
+ }
657
+ const lengths = visiblePieces.map((piece) => Math.min(terminalWidth(piece.value), piece.max));
658
+ while (sum(lengths) > available) {
659
+ const index = largestShrinkablePieceIndex(visiblePieces, lengths);
660
+ if (index === -1)
661
+ break;
662
+ lengths[index] -= 1;
663
+ }
664
+ return visiblePieces
665
+ .map((piece, index) => piece.ellipsize(piece.value, lengths[index]))
666
+ .join(separator);
667
+ }
668
+ function minimumPieceLength(pieces) {
669
+ return sum(pieces.map((piece) => Math.min(terminalWidth(piece.value), piece.min)));
670
+ }
671
+ function largestShrinkablePieceIndex(pieces, lengths) {
672
+ let candidate = -1;
673
+ for (let index = 0; index < pieces.length; index += 1) {
674
+ if (lengths[index] <=
675
+ Math.min(terminalWidth(pieces[index].value), pieces[index].min)) {
676
+ continue;
677
+ }
678
+ if (candidate === -1 || lengths[index] > lengths[candidate]) {
679
+ candidate = index;
680
+ }
681
+ }
682
+ return candidate;
683
+ }
684
+ function sum(values) {
685
+ return values.reduce((total, value) => total + value, 0);
686
+ }
687
+ function filterSearchablePromptEntries(entries, query) {
688
+ const filtered = [];
689
+ let pendingGroup = null;
690
+ for (const entry of entries) {
691
+ if (!isSelectableEntry(entry)) {
692
+ pendingGroup = entry;
693
+ continue;
694
+ }
695
+ if (!entry.searchText.includes(query)) {
696
+ continue;
697
+ }
698
+ if (pendingGroup !== null) {
699
+ filtered.push(pendingGroup);
700
+ pendingGroup = null;
701
+ }
702
+ filtered.push(entry);
703
+ }
704
+ return filtered;
705
+ }
706
+ function isSelectableEntry(entry) {
707
+ return (entry !== undefined &&
708
+ entry.selectable !== false &&
709
+ typeof entry.value === "string");
710
+ }
711
+ function buildPromptSearchText(worktree) {
712
+ return [
713
+ worktree.repoName,
714
+ worktree.branch ?? "detached",
715
+ worktree.path,
716
+ worktree.label,
717
+ `${worktree.repoName}/${worktree.branch ?? "detached"}`,
718
+ ]
719
+ .join(" ")
720
+ .toLowerCase();
721
+ }
722
+ function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
723
+ const lastWorkedTimestamp = info.lastCommitTimestamp === null ? null : info.lastCommitTimestamp * 1000;
724
+ const lastActivityTimestamp = lastUsedTimestamp ?? lastWorkedTimestamp;
725
+ const lastActivityType = lastUsedTimestamp !== null
726
+ ? "used"
727
+ : lastWorkedTimestamp !== null
728
+ ? "worked"
729
+ : null;
730
+ const branch = source.worktree.branch ?? "(detached)";
731
+ const badges = buildStatusBadges(info);
732
+ const recency = formatPromptRecency(lastActivityTimestamp, lastActivityType, now);
733
+ const status = badges.length > 0 ? badges.map((badge) => `[${badge}]`).join(" ") : null;
734
+ const metadataParts = [status, recency].filter((part) => part !== null && part.length > 0);
735
+ const metadata = metadataParts.length === 0 ? null : metadataParts.join(" · ");
736
+ const path = middleEllipsize(source.worktree.path, 76);
737
+ const label = [
738
+ middleEllipsize(source.repoName, 22),
739
+ middleEllipsize(branch, 34),
740
+ metadata,
741
+ path,
742
+ ]
743
+ .filter((part) => part !== null && part.length > 0)
744
+ .join(" · ");
745
+ return {
746
+ ...source.worktree,
747
+ group: lastUsedTimestamp !== null ? "recent" : "other",
748
+ label,
749
+ lastActivityTimestamp,
750
+ metadata,
751
+ repoName: source.repoName,
752
+ };
753
+ }
754
+ function buildStatusBadges(info) {
755
+ const badges = [];
756
+ if (info.isCurrent) {
757
+ badges.push("current");
758
+ }
759
+ if (info.branch === null) {
760
+ badges.push("detached");
761
+ }
762
+ if (info.status === "dirty") {
763
+ badges.push("dirty");
764
+ }
765
+ if (info.upstream.kind === "stale") {
766
+ badges.push("stale", "gone");
767
+ }
768
+ if (isUpToDate(info.upstream)) {
769
+ badges.push("up to date");
770
+ }
771
+ return badges;
772
+ }
773
+ function isUpToDate(upstream) {
774
+ return (upstream.kind === "tracked" && upstream.ahead === 0 && upstream.behind === 0);
775
+ }
776
+ function formatPromptRecency(timestamp, type, now) {
777
+ if (timestamp === null || type === null) {
778
+ return "last used: never";
779
+ }
780
+ const label = type === "used" ? "last used" : "last worked";
781
+ return `${label}: ${formatPickerAge(timestamp, now)}`;
782
+ }
783
+ function formatPickerAge(timestamp, now) {
784
+ const ageSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
785
+ if (ageSeconds < 60) {
786
+ return "now";
787
+ }
788
+ if (ageSeconds < 60 * 60) {
789
+ return `${Math.floor(ageSeconds / 60)}m ago`;
790
+ }
791
+ if (ageSeconds < 24 * 60 * 60) {
792
+ return `${Math.floor(ageSeconds / (60 * 60))}h ago`;
793
+ }
794
+ if (isYesterday(timestamp, now)) {
795
+ return "yesterday";
796
+ }
797
+ return new Intl.DateTimeFormat("en-US", {
798
+ day: "numeric",
799
+ month: "short",
800
+ }).format(new Date(timestamp));
801
+ }
802
+ function isYesterday(timestamp, now) {
803
+ const date = new Date(timestamp);
804
+ const yesterday = new Date(now);
805
+ yesterday.setDate(yesterday.getDate() - 1);
806
+ return (date.getFullYear() === yesterday.getFullYear() &&
807
+ date.getMonth() === yesterday.getMonth() &&
808
+ date.getDate() === yesterday.getDate());
809
+ }
810
+ function buildSearchText(repoName, worktree) {
811
+ return [
812
+ repoName,
813
+ worktree.branch ?? "detached",
814
+ worktree.path,
815
+ `${repoName}/${worktree.branch ?? "detached"}`,
816
+ ]
817
+ .join(" ")
818
+ .toLowerCase();
819
+ }
820
+ function normalizeQuery(query) {
821
+ const normalized = query?.trim().toLowerCase();
822
+ return normalized && normalized.length > 0 ? normalized : null;
823
+ }
824
+ function comparePromptEntries(a, b) {
825
+ if (a.isCurrent && !b.isCurrent)
826
+ return -1;
827
+ if (!a.isCurrent && b.isCurrent)
828
+ return 1;
829
+ if (a.group !== b.group) {
830
+ return groupRank(a.group) - groupRank(b.group);
831
+ }
832
+ const aRecent = a.lastActivityTimestamp ?? 0;
833
+ const bRecent = b.lastActivityTimestamp ?? 0;
834
+ if (aRecent !== bRecent) {
835
+ return bRecent - aRecent;
836
+ }
837
+ return (a.repoName.localeCompare(b.repoName) ||
838
+ (a.branch ?? "").localeCompare(b.branch ?? "") ||
839
+ a.path.localeCompare(b.path));
840
+ }
841
+ function groupRank(group) {
842
+ return group === "recent" ? 0 : 1;
843
+ }
844
+ function scoreWorktreeMatch(entry, query) {
845
+ const branch = entry.branch ?? "detached";
846
+ const exactCandidates = [
847
+ branch,
848
+ entry.path,
849
+ `${entry.repoName}/${branch}`,
850
+ ].map((candidate) => candidate.toLowerCase());
851
+ if (exactCandidates.includes(query)) {
852
+ return 1000;
853
+ }
854
+ return buildSearchText(entry.repoName, entry).includes(query) ? 1 : null;
855
+ }
856
+ function middleEllipsize(value, maxLength) {
857
+ if (terminalWidth(value) <= maxLength) {
858
+ return value;
859
+ }
860
+ if (maxLength <= 1) {
861
+ return "…";
862
+ }
863
+ const keep = maxLength - 1;
864
+ const start = takeTerminalColumns(value, Math.ceil(keep / 2), "start");
865
+ const end = takeTerminalColumns(value, Math.floor(keep / 2), "end");
866
+ return `${start}…${end}`;
867
+ }
868
+ function startEllipsize(value, maxLength) {
869
+ if (terminalWidth(value) <= maxLength) {
870
+ return value;
871
+ }
872
+ if (maxLength <= 1) {
873
+ return "…";
874
+ }
875
+ return `…${takeTerminalColumns(value, maxLength - 1, "end")}`;
876
+ }
877
+ function takeTerminalColumns(value, maxWidth, direction) {
878
+ const characters = direction === "start" ? Array.from(value) : Array.from(value).reverse();
879
+ const kept = [];
880
+ let width = 0;
881
+ for (const character of characters) {
882
+ const characterWidth = terminalWidth(character);
883
+ if (width + characterWidth > maxWidth)
884
+ break;
885
+ kept.push(character);
886
+ width += characterWidth;
887
+ }
888
+ return direction === "start" ? kept.join("") : kept.reverse().join("");
889
+ }
890
+ function terminalWidth(value) {
891
+ let width = 0;
892
+ for (const character of Array.from(value)) {
893
+ width += characterTerminalWidth(character);
894
+ }
895
+ return width;
896
+ }
897
+ function characterTerminalWidth(character) {
898
+ const codePoint = character.codePointAt(0);
899
+ if (codePoint === undefined)
900
+ return 0;
901
+ if (isZeroWidthCodePoint(codePoint) || /\p{Mark}/u.test(character)) {
902
+ return 0;
903
+ }
904
+ return isWideCodePoint(codePoint) ? 2 : 1;
905
+ }
906
+ function isZeroWidthCodePoint(codePoint) {
907
+ return (codePoint === 0 ||
908
+ codePoint === 0x200d ||
909
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f));
910
+ }
911
+ function isWideCodePoint(codePoint) {
912
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) ||
913
+ codePoint === 0x2329 ||
914
+ codePoint === 0x232a ||
915
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
916
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
917
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
918
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
919
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
920
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
921
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
922
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
923
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd));
924
+ }