@solaqua/gji 0.7.2 → 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.
@@ -1,4 +1,5 @@
1
- import { groupMultiselect, isCancel, select } from "@clack/prompts";
1
+ import { env, platform, stdin, stdout } from "node:process";
2
+ import { isCancel, Prompt } from "@clack/core";
2
3
  import { loadHistory } from "./history.js";
3
4
  import { readWorktreeInfos, } from "./worktree-info.js";
4
5
  export async function buildWorktreePromptEntries(sources) {
@@ -38,25 +39,25 @@ function isAmbiguousRepoOnlyQuery(matches, query) {
38
39
  return (matches.filter((match) => match.source.repoName.toLowerCase() === query)
39
40
  .length > 1);
40
41
  }
41
- export async function promptForSingleWorktree(message, worktrees) {
42
- const choice = await select({
42
+ export async function promptForSingleWorktree(message, worktrees, io = {}) {
43
+ const choice = await runSearchablePrompt({
44
+ entries: worktrees.map(buildSearchableWorktreeEntry),
45
+ input: io.input,
43
46
  message,
44
- options: worktrees.map((worktree) => ({
45
- label: worktree.label,
46
- value: worktree.path,
47
- })),
48
- maxItems: 12,
47
+ multiple: false,
48
+ output: io.output,
49
49
  });
50
- return isCancel(choice) ? null : choice;
50
+ return typeof choice === "string" ? choice : null;
51
51
  }
52
- export async function promptForMultipleWorktrees(message, worktrees) {
53
- const choice = await groupMultiselect({
52
+ export async function promptForMultipleWorktrees(message, worktrees, io = {}) {
53
+ const choice = await runSearchablePrompt({
54
+ entries: buildGroupedSearchableEntries(worktrees),
55
+ input: io.input,
54
56
  message,
55
- options: groupPromptEntries(worktrees),
56
- required: true,
57
- selectableGroups: false,
57
+ multiple: true,
58
+ output: io.output,
58
59
  });
59
- return isCancel(choice) ? null : choice;
60
+ return Array.isArray(choice) ? choice : null;
60
61
  }
61
62
  function compareQueryMatches(a, b) {
62
63
  if (a.matchScore !== b.matchScore) {
@@ -71,16 +72,652 @@ function compareQueryMatches(a, b) {
71
72
  a.source.worktree.path.localeCompare(b.source.worktree.path));
72
73
  }
73
74
  function groupPromptEntries(worktrees) {
74
- const groups = {};
75
+ const groups = new Map();
75
76
  for (const worktree of worktrees) {
76
77
  const group = worktree.group === "recent" ? "Recent worktrees" : "Other worktrees";
77
- groups[group] ??= [];
78
- groups[group].push({
79
- label: worktree.label,
80
- value: worktree.path,
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,
81
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
+ }
82
681
  }
83
- return groups;
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();
84
721
  }
85
722
  function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
86
723
  const lastWorkedTimestamp = info.lastCommitTimestamp === null ? null : info.lastCommitTimestamp * 1000;
@@ -94,12 +731,13 @@ function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
94
731
  const badges = buildStatusBadges(info);
95
732
  const recency = formatPromptRecency(lastActivityTimestamp, lastActivityType, now);
96
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(" · ");
97
736
  const path = middleEllipsize(source.worktree.path, 76);
98
737
  const label = [
99
738
  middleEllipsize(source.repoName, 22),
100
739
  middleEllipsize(branch, 34),
101
- status,
102
- recency,
740
+ metadata,
103
741
  path,
104
742
  ]
105
743
  .filter((part) => part !== null && part.length > 0)
@@ -109,6 +747,7 @@ function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
109
747
  group: lastUsedTimestamp !== null ? "recent" : "other",
110
748
  label,
111
749
  lastActivityTimestamp,
750
+ metadata,
112
751
  repoName: source.repoName,
113
752
  };
114
753
  }
@@ -215,14 +854,71 @@ function scoreWorktreeMatch(entry, query) {
215
854
  return buildSearchText(entry.repoName, entry).includes(query) ? 1 : null;
216
855
  }
217
856
  function middleEllipsize(value, maxLength) {
218
- if (value.length <= maxLength) {
857
+ if (terminalWidth(value) <= maxLength) {
219
858
  return value;
220
859
  }
221
860
  if (maxLength <= 1) {
222
861
  return "…";
223
862
  }
224
863
  const keep = maxLength - 1;
225
- const start = Math.ceil(keep / 2);
226
- const end = Math.floor(keep / 2);
227
- return `${value.slice(0, start)}…${value.slice(value.length - end)}`;
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));
228
924
  }