@leavepulse/ui 0.46.0 → 0.47.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.
Files changed (64) hide show
  1. package/dist/assets/HighlightText-BwC5xbqn.css +6 -0
  2. package/dist/assets/{LpCommandPalette-5miKYsb-.css → LpCommandPalette-DmQUsJqA.css} +21 -21
  3. package/dist/assets/LpKeys-BxRhfQda.css +36 -0
  4. package/dist/assets/LpPaletteFacts-B9FdVmIO.css +93 -0
  5. package/dist/assets/LpPaletteHeader-BVOu1UX5.css +70 -0
  6. package/dist/assets/LpPalettePreview-CJmAKgYW.css +43 -0
  7. package/dist/assets/LpPaletteStats-ClE-37MK.css +33 -0
  8. package/dist/assets/LpPaletteSwatches-CA0B8y2E.css +21 -0
  9. package/dist/assets/{LpShortcutsDialog-CqYX3yy6.css → LpShortcutsDialog-BUp_BX4s.css} +7 -28
  10. package/dist/assets/{LpTable-BoeA_TgK.css → LpTable-xWwI4KPZ.css} +17 -17
  11. package/dist/chunks/HighlightText-BvnYZkhi.js +27 -0
  12. package/dist/chunks/{LpAvatar-DeSb6H0c.js → LpAvatar-Cb1jtOVv.js} +21 -3
  13. package/dist/chunks/{LpLightbox-DSDahF23.js → LpLightbox-EVXzAq62.js} +3 -3
  14. package/dist/chunks/{LpMediaGallery-YKr-cQPN.js → LpMediaGallery-BnlkN_1f.js} +1 -1
  15. package/dist/chunks/LpPalettePreview-CFav_7co.js +19 -0
  16. package/dist/component-names.d.ts +1 -1
  17. package/dist/components/HighlightText.vue.d.ts +7 -0
  18. package/dist/components/HighlightText.vue.js +4 -0
  19. package/dist/components/LpAvatar.vue.d.ts +12 -0
  20. package/dist/components/LpAvatar.vue.js +1 -1
  21. package/dist/components/LpCommandPalette.vue.d.ts +152 -15
  22. package/dist/components/LpCommandPalette.vue.js +178 -92
  23. package/dist/components/LpKeys.vue.d.ts +22 -0
  24. package/dist/components/LpKeys.vue.js +52 -0
  25. package/dist/components/LpLightbox.vue.js +1 -1
  26. package/dist/components/LpMediaGallery.vue.js +1 -1
  27. package/dist/components/LpPaletteFacts.vue.d.ts +25 -0
  28. package/dist/components/LpPaletteFacts.vue.js +85 -0
  29. package/dist/components/LpPaletteHeader.vue.d.ts +32 -0
  30. package/dist/components/LpPaletteHeader.vue.js +103 -0
  31. package/dist/components/LpPalettePreview.vue.d.ts +19 -0
  32. package/dist/components/LpPalettePreview.vue.js +4 -0
  33. package/dist/components/LpPaletteStats.vue.d.ts +11 -0
  34. package/dist/components/LpPaletteStats.vue.js +47 -0
  35. package/dist/components/LpPaletteSwatches.vue.d.ts +10 -0
  36. package/dist/components/LpPaletteSwatches.vue.js +34 -0
  37. package/dist/components/LpShortcutsDialog.vue.js +8 -28
  38. package/dist/components/LpTable.vue.js +5 -3
  39. package/dist/components/commandPalette.d.ts +157 -0
  40. package/dist/components/commandPalette.js +103 -0
  41. package/dist/components/commandSearch.d.ts +32 -0
  42. package/dist/components/commandSearch.js +48 -0
  43. package/dist/components/keys.d.ts +2 -0
  44. package/dist/components/keys.js +11 -0
  45. package/dist/index.d.ts +12 -3
  46. package/dist/index.js +157 -134
  47. package/package.json +1 -1
  48. package/src/component-names.ts +6 -0
  49. package/src/components/HighlightText.vue +28 -0
  50. package/src/components/LpAvatar.vue +21 -2
  51. package/src/components/LpCommandPalette.vue +387 -87
  52. package/src/components/LpKeys.vue +79 -0
  53. package/src/components/LpLightbox.vue +4 -1
  54. package/src/components/LpPaletteFacts.vue +165 -0
  55. package/src/components/LpPaletteHeader.vue +155 -0
  56. package/src/components/LpPalettePreview.vue +77 -0
  57. package/src/components/LpPaletteStats.vue +81 -0
  58. package/src/components/LpPaletteSwatches.vue +56 -0
  59. package/src/components/LpShortcutsDialog.vue +2 -45
  60. package/src/components/LpTable.vue +6 -1
  61. package/src/components/commandPalette.ts +264 -0
  62. package/src/components/commandSearch.ts +79 -0
  63. package/src/components/keys.ts +13 -0
  64. package/src/index.ts +12 -3
@@ -37,6 +37,7 @@ import {
37
37
  DialogTitle,
38
38
  } from "reka-ui"
39
39
  import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useId, watch } from "vue"
40
+ import LpAvatar from "./LpAvatar.vue"
40
41
  import LpIcon from "./LpIcon.vue"
41
42
  import LpScrollArea from "./LpScrollArea.vue"
42
43
  import { useAsyncTask } from "../composables/useAsyncTask"
@@ -45,7 +46,44 @@ import { useClipboard } from "../composables/useClipboard"
45
46
  import { useHotkeys } from "../composables/useHotkeys"
46
47
  import { useModalLayer } from "./modalDepth"
47
48
  import { useLabels } from "../composables/useLpConfig"
48
- import { parseCommandQuery, rankCommandItems } from "./commandSearch"
49
+ import {
50
+ applyQueryFilters,
51
+ highlightParts,
52
+ parseCommandQuery,
53
+ parsePaletteQuery,
54
+ rankCommandItems,
55
+ type HighlightPart,
56
+ type PaletteQuery,
57
+ } from "./commandSearch"
58
+ import {
59
+ commandHasPreview,
60
+ fallbackRows,
61
+ matchModePrefix,
62
+ modeChipLabel,
63
+ pushRecent,
64
+ readRecent,
65
+ recentVisible,
66
+ resolveRecentItems,
67
+ toRecentItem,
68
+ type RecentEntry,
69
+ type RecentItem,
70
+ } from "./commandPalette"
71
+
72
+ /**
73
+ * A picture in a row's icon slot: an avatar, a project logo, a world's
74
+ * thumbnail. While it loads, and for good if it fails, the row shows
75
+ * `fallback` (initials) or else `icon`, so a dead link never leaves a hole.
76
+ */
77
+ export interface CommandImage {
78
+ src?: string | null
79
+ alt?: string
80
+ /** Text shown while there is no picture, usually initials. */
81
+ fallback?: string
82
+ /** Icon shown while there is no picture; the command's `icon` when unset. */
83
+ icon?: string
84
+ /** circle for people, soft or square for things. Defaults to soft. */
85
+ shape?: "circle" | "soft" | "square"
86
+ }
49
87
 
50
88
  export interface Command {
51
89
  id: string
@@ -66,6 +104,12 @@ export interface Command {
66
104
  */
67
105
  highlight?: string[]
68
106
  icon?: string
107
+ /**
108
+ * A picture in place of the icon: a URL, or a `CommandImage` with a
109
+ * fallback. Sized like the icon and faded in once loaded; until then, and
110
+ * when it fails, the fallback (or `icon`) holds the slot.
111
+ */
112
+ image?: string | CommandImage
69
113
  /** Right-aligned hint, e.g. "⌘P". */
70
114
  shortcut?: string
71
115
  /** Extra search terms not shown but matched against the query. */
@@ -86,11 +130,60 @@ export interface Command {
86
130
  * and what lets the second step search a list too big to enumerate.
87
131
  *
88
132
  * `onSelect` still runs when the mode opens, so a command can both act and
89
- * drill in.
133
+ * drill in. A fetcher is called like `search` (see CommandFetcher): its
134
+ * abort signal may never reach the transport, and an answer that lands
135
+ * after the query moved on or the mode was left is discarded.
90
136
  */
91
- children?: Command[] | ((query: string, signal: AbortSignal) => Command[] | Promise<Command[]>)
137
+ children?: Command[] | CommandFetcher
92
138
  /** Placeholder while inside this command's mode. */
93
139
  childPlaceholder?: string
140
+ /**
141
+ * The mode's name in the chip beside the box, when the row's label is a
142
+ * sentence: "Mods" for a row reading `Search mods for "{query}"`. Without
143
+ * it a `{query}` placeholder and its quotes are dropped from the label.
144
+ */
145
+ modeLabel?: string
146
+ /**
147
+ * Typed at the start of the box, enters this command's mode directly, and
148
+ * whatever follows the prefix becomes the query inside it: ">" for
149
+ * commands, "mr " for a mod search. Only for a command with `children`,
150
+ * matched against the list on screen (the root, or the open mode's own
151
+ * static children), longest prefix first, letters in any case. Entering
152
+ * this way does not run `onSelect`. The row shows the prefix as its hint
153
+ * when it has no `shortcut`.
154
+ */
155
+ prefix?: string
156
+ /**
157
+ * Opening this mode takes the query typed so far along, operators and
158
+ * all, so typing "sodium", arrowing to "Search mods" and pressing Enter
159
+ * opens the mod search already searching for sodium instead of starting
160
+ * it empty. Backspace out of the mode restores the query as it was.
161
+ */
162
+ carryQuery?: boolean
163
+ /**
164
+ * A "search somewhere else" row: listed only while words are typed, for
165
+ * any words (it is not matched against them), after local and fetched
166
+ * results: at the end of the list, or of its group when that group is
167
+ * already listed. `{query}` in the label becomes the
168
+ * typed words without the operators, `Search Modrinth for "{query}"`;
169
+ * `-word` and `in:group` still apply to the row's own label, keywords and
170
+ * group. Usually a mode with `carryQuery`, so picking it runs the search.
171
+ */
172
+ fallback?: boolean
173
+ /**
174
+ * Keep this out of the recent history. Picks are remembered wherever they
175
+ * are made, inside modes and among fetched results too; a pick that is a
176
+ * setting rather than a destination (a language, a zoom step) can opt out.
177
+ * Rows offered by Tab (`actions`) are never remembered.
178
+ */
179
+ remember?: boolean
180
+ /**
181
+ * A JSON-safe value stored with this command in the recent history and
182
+ * handed back to `resolveRecent`: what an app needs to rebuild a result
183
+ * that a search fetched (a project id, a hit), since the command's
184
+ * functions cannot be stored.
185
+ */
186
+ recentData?: unknown
94
187
  /**
95
188
  * Keep the palette open after this runs. For a command that toggles
96
189
  * something the user may want to toggle again (a theme, a filter) without
@@ -118,9 +211,41 @@ export interface Command {
118
211
  * breaks are kept. Without it the pane shows the `excerpt`.
119
212
  */
120
213
  preview?: string
214
+ /**
215
+ * This row has nothing for the pane, even with a `#preview` slot: the pane
216
+ * folds away while it is highlighted instead of the slot having to draw a
217
+ * placeholder. For a whole class of rows, `hasPreview` says it once.
218
+ */
219
+ noPreview?: boolean
121
220
  onSelect?: () => void
122
221
  }
123
222
 
223
+ /**
224
+ * Commands fetched for a query: `search` at the root, a function `children`
225
+ * inside a mode.
226
+ *
227
+ * `query` is the box's positive words only, in the case they were typed:
228
+ * `sodium -forge in:mods` arrives as "sodium", because a server would treat
229
+ * the operators as words to find. They come in `parsed` instead (a
230
+ * PaletteQuery: `raw`, `text`, lowercased `include`, `exclude` and `groups`)
231
+ * for a provider that can scope or exclude on its side; either way the
232
+ * palette drops returned rows that match an exclusion or miss an `in:` scope.
233
+ *
234
+ * `signal` aborts when the query moves on, the mode is left or the palette
235
+ * closes. It is a courtesy, not a guarantee: hand it to `fetch` and the
236
+ * request is cancelled, but a transport that cannot be cancelled (a Tauri
237
+ * `invoke`, a worker message, most SDKs) never sees it and runs to the end.
238
+ * That is safe: an answer that lands after a newer request was made, or
239
+ * after the palette closed, is discarded and never reaches the list, so a
240
+ * fetcher does not need to check the signal itself. Checking it before an
241
+ * expensive step is only a saving.
242
+ */
243
+ export type CommandFetcher = (
244
+ query: string,
245
+ signal: AbortSignal,
246
+ parsed: PaletteQuery,
247
+ ) => Command[] | Promise<Command[]>
248
+
124
249
  /** Footer hints and the mode chip, spelled out so an app can translate them. */
125
250
  export type PaletteLabels = {
126
251
  navigate: string
@@ -165,28 +290,46 @@ const props = withDefaults(
165
290
  * (or too remote) to hand over as a list: people, files, tickets. Called
166
291
  * debounced, never for a query shorter than `minChars`, and only its
167
292
  * newest call counts: an earlier one that lands late is dropped, so a slow
168
- * response can't overwrite a fresher list.
293
+ * response can't overwrite a fresher list. The abort signal may never
294
+ * reach the transport (a Tauri invoke cannot be cancelled); the late
295
+ * answer is discarded all the same. See CommandFetcher for what
296
+ * `query` and `parsed` hold: the operators are stripped from the first,
297
+ * and the palette applies them to the results itself.
169
298
  *
170
299
  * Results join the static commands, so a palette can do both.
171
300
  */
172
- search?: (query: string, signal: AbortSignal) => Command[] | Promise<Command[]>
301
+ search?: CommandFetcher
173
302
  /** Wait after the last keystroke before `search` runs. */
174
303
  debounce?: number
175
- /** Shortest query `search` runs for. */
304
+ /** Shortest query `search` runs for, counted on its positive words. */
176
305
  minChars?: number
177
306
  /**
178
- * Ids of recently picked commands, newest first — bind it to storage and
179
- * the palette opens on what this person actually uses. It only reorders
180
- * commands already in the list; an id that no longer resolves is skipped,
181
- * so a stored history never resurrects a command that has gone away.
307
+ * Recently picked commands, newest first — bind it to storage and the
308
+ * palette opens on what this person actually uses. An item is a root
309
+ * command's id, or a RecentEntry for a pick made inside a mode or among
310
+ * fetched results (the `remember` event hands over exactly what to
311
+ * store; `pushRecent` adds it to a list). A root id only reorders
312
+ * commands already in the list. An entry is found again down its path
313
+ * of static children, then through `resolveRecent`, then from its own
314
+ * `href`; what none of them finds is skipped, so a stored history never
315
+ * resurrects a command that has gone away.
182
316
  */
183
- recent?: string[]
317
+ recent?: RecentItem[]
184
318
  /**
185
319
  * Let the palette keep the history itself, in localStorage under this key:
186
- * every top-level command run is remembered, newest first. Ignored while
187
- * `recent` is bound, which stays the way to store it somewhere else.
320
+ * every pick is remembered, newest first, root ones as bare ids as before
321
+ * and deeper ones as entries. Ignored while `recent` is bound, which
322
+ * stays the way to store it somewhere else.
188
323
  */
189
324
  recentKey?: string
325
+ /**
326
+ * Finds the live command for a remembered pick the palette cannot find
327
+ * itself: a result a fetcher returned, or one inside a mode whose
328
+ * children are fetched. Gets the stored entry, with the command's
329
+ * `recentData` as `data`; returns the command to show and run, or
330
+ * nothing to drop it.
331
+ */
332
+ resolveRecent?: (entry: RecentEntry) => Command | null | undefined
190
333
  /** How many recent commands to surface. */
191
334
  recentLimit?: number
192
335
  /** Headings the palette adds itself. */
@@ -207,8 +350,10 @@ const props = withDefaults(
207
350
  * `preview` text, or the `preview` slot. For palettes that search
208
351
  * content rather than commands: the excerpt says why a row matched, the
209
352
  * pane lets it be read without leaving the page. It opens only for a row
210
- * with `preview` or `excerpt` (any row, with the slot): the dialog eases
211
- * wider, the pane slides in, and the text arrives paragraph by paragraph.
353
+ * with `preview` or `excerpt` (any row, with the slot, unless `hasPreview`
354
+ * or the row's `noPreview` says otherwise): the dialog eases wider, the
355
+ * pane slides in, and the text arrives paragraph by paragraph. A slot
356
+ * that renders nothing for a row falls back to that built-in text.
212
357
  * Hidden below the md breakpoint, where there is no room beside the list.
213
358
  *
214
359
  * The width is an inline style rather than a pair of classes: a base
@@ -216,6 +361,14 @@ const props = withDefaults(
216
361
  * build in an order where the base won, and the pane opened squeezed.
217
362
  */
218
363
  previewPane?: boolean
364
+ /**
365
+ * Which rows the pane opens for. With a `#preview` slot every row would
366
+ * otherwise qualify, and an app whose previews cover only some kinds of
367
+ * row had to draw a placeholder for the rest. Return false and the pane
368
+ * folds away for that row, as it does for a row without text. A row's
369
+ * own `noPreview` wins over this.
370
+ */
371
+ hasPreview?: (command: Command) => boolean
219
372
  /**
220
373
  * Frosted glass instead of a solid panel: the page shows through,
221
374
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
@@ -274,6 +427,13 @@ const emit = defineEmits<{
274
427
  * navigation, not a pick.
275
428
  */
276
429
  (e: "run", id: string, command: Command): void
430
+ /**
431
+ * A pick the recent history keeps, as the item to store: a bare id for a
432
+ * root command, a RecentEntry for anything deeper. For apps that bind
433
+ * `recent` and keep the history themselves: `recent = pushRecent(recent,
434
+ * item, 20)`.
435
+ */
436
+ (e: "remember", item: RecentItem, command: Command): void
277
437
  }>()
278
438
 
279
439
  const query = ref("")
@@ -354,7 +514,9 @@ function onTab(event: KeyboardEvent) {
354
514
  if (!c || !actions.length) return
355
515
  event.preventDefault()
356
516
  event.stopPropagation()
357
- enter({ id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions })
517
+ const actionsMode: Command = { id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions }
518
+ syntheticModes.add(actionsMode)
519
+ enter(actionsMode)
358
520
  }
359
521
 
360
522
  /*
@@ -382,8 +544,8 @@ watch(
382
544
  },
383
545
  )
384
546
 
385
- function enter(c: Command) {
386
- trailState.value = [...trailState.value, { query: query.value, selectedId: selectedId.value }]
547
+ function enter(c: Command, parentQuery = query.value) {
548
+ trailState.value = [...trailState.value, { query: parentQuery, selectedId: selectedId.value }]
387
549
  trail.value = [...trail.value, c]
388
550
  query.value = ""
389
551
  select(0)
@@ -431,7 +593,7 @@ const found = shallowRef<Command[]>([])
431
593
  const task = useAsyncTask<Command[]>()
432
594
  const searching = task.pending
433
595
 
434
- function fetcher(): ((q: string, signal: AbortSignal) => Command[] | Promise<Command[]>) | null {
596
+ function fetcher(): CommandFetcher | null {
435
597
  const current = mode.value
436
598
  if (current) {
437
599
  return typeof current.children === "function"
@@ -459,7 +621,8 @@ function refresh() {
459
621
  failed.value = false
460
622
  const run = fetcher()
461
623
  if (!run) return
462
- const q = query.value.trim()
624
+ const parsed = parsePaletteQuery(query.value.trim())
625
+ const q = parsed.text
463
626
  // A fetching mode runs on an empty query too: opening it should show the
464
627
  // first page, not an empty screen waiting for a keystroke. Root search does
465
628
  // not — ⌘K would fire a request at every open.
@@ -467,7 +630,7 @@ function refresh() {
467
630
  if (!inMode && q.length < Math.max(1, props.minChars)) return
468
631
  // A failing provider: the static commands still stand, with a line saying
469
632
  // that the rest could not be fetched.
470
- task.run((signal) => run(q, signal), {
633
+ task.run((signal) => run(q, signal, parsed), {
471
634
  delay: props.debounce,
472
635
  apply: (hits) => (found.value = hits),
473
636
  fail: () => {
@@ -498,9 +661,10 @@ const source = computed<Command[]>(() => (mode.value ? modeItems.value : props.c
498
661
  */
499
662
  const local = computed<{ items: Command[]; terms: string[] }>(() => {
500
663
  const terms = parseCommandQuery(query.value)
664
+ // Fallback rows are not matched against the query; they get their own pass.
501
665
  const filtered = query.value.trim()
502
- ? source.value
503
- : source.value.filter((c) => !c.hidden)
666
+ ? source.value.filter((c) => !c.fallback)
667
+ : source.value.filter((c) => !c.hidden && !c.fallback)
504
668
  // Empty query: show everything EXCEPT hidden commands (they only surface once
505
669
  // typed). With a query, hidden commands rank like any other.
506
670
  if (!terms.include.length && !terms.exclude.length && !terms.groups.length) return { items: filtered, terms: [] }
@@ -519,48 +683,91 @@ const ranked = computed<Command[]>(() => local.value.items)
519
683
  const fetched = computed<Command[]>(() => {
520
684
  if (!found.value.length) return []
521
685
  const seen = new Set(ranked.value.map((c) => c.id))
522
- return found.value
686
+ const rows = found.value
523
687
  .filter((c) => !seen.has(c.id))
524
688
  .map((c) => (c.group ? c : { ...c, group: props.resultsLabel }))
689
+ return applyQueryFilters(rows, parseCommandQuery(query.value))
525
690
  })
526
691
 
692
+ /*
693
+ * "Search elsewhere" rows for the typed words, labelled with them. The row
694
+ * on screen is a copy with the label filled in; the command it stands for
695
+ * is what runs and what a mode chip names.
696
+ */
697
+ const fallbackOf = new WeakMap<Command, Command>()
698
+ const fallbacks = computed<Command[]>(() =>
699
+ fallbackRows(source.value, query.value).map(({ command, label }) => {
700
+ const row = { ...command, label }
701
+ fallbackOf.set(row, command)
702
+ return row
703
+ }),
704
+ )
705
+
527
706
  /*
528
707
  * Recent picks, newest first, lifted out of the list they already belong to.
529
708
  * Only on the root's empty query: once someone types, or drills into a mode,
530
709
  * relevance is the better order and a duplicated row is just noise.
531
710
  */
532
711
  const RECENT_KEEP = 20
533
- const ownRecent = ref<string[]>([])
712
+ const ownRecent = ref<RecentItem[]>([])
534
713
  watch(
535
714
  () => props.recentKey,
536
715
  (key) => {
537
716
  if (!key || typeof localStorage === "undefined") return
538
717
  try {
539
- const stored: unknown = JSON.parse(localStorage.getItem(key) ?? "[]")
540
- ownRecent.value = Array.isArray(stored) ? stored.filter((v): v is string => typeof v === "string") : []
718
+ ownRecent.value = readRecent(JSON.parse(localStorage.getItem(key) ?? "[]"))
541
719
  } catch {
542
720
  ownRecent.value = []
543
721
  }
544
722
  },
545
723
  { immediate: true },
546
724
  )
547
- function remember(id: string) {
725
+
726
+ /*
727
+ * Where each recent row was found, so running it again remembers it under
728
+ * the same path rather than as a root pick. Modes the palette makes up
729
+ * itself (Tab's actions) are not destinations, and nothing picked in them
730
+ * is remembered.
731
+ */
732
+ const recentPath = new WeakMap<Command, string[]>()
733
+ const syntheticModes = new WeakSet<Command>()
734
+
735
+ function remember(c: Command) {
736
+ if (c.remember === false || trail.value.some((step) => syntheticModes.has(step))) return
737
+ const path = recentPath.get(c) ?? trail.value.map((step) => step.id)
738
+ const item = toRecentItem(c, path)
739
+ emit("remember", item, c)
548
740
  const key = props.recentKey
549
741
  if (!key || props.recent) return
550
- ownRecent.value = [id, ...ownRecent.value.filter((v) => v !== id)].slice(0, RECENT_KEEP)
742
+ ownRecent.value = pushRecent(ownRecent.value, item, RECENT_KEEP)
551
743
  try {
552
744
  localStorage.setItem(key, JSON.stringify(ownRecent.value))
553
745
  } catch {}
554
746
  }
555
- const recentIds = computed(() => props.recent ?? (props.recentKey ? ownRecent.value : []))
747
+ const recentList = computed(() => props.recent ?? (props.recentKey ? ownRecent.value : []))
748
+
749
+ function fromEntry(entry: RecentEntry): Command {
750
+ return { id: entry.id, label: entry.label, description: entry.description, icon: entry.icon, image: entry.image, href: entry.href }
751
+ }
556
752
 
753
+ /*
754
+ * Shown while no words are typed: at an empty box, and under operators
755
+ * alone, which filter them like any row ("-theme", "in:recent").
756
+ */
557
757
  const recentItems = computed<Command[]>(() => {
558
- if (mode.value || query.value.trim() || !recentIds.value.length) return []
559
- const byId = new Map(props.commands.map((c) => [c.id, c]))
758
+ if (mode.value || !recentList.value.length) return []
759
+ const terms = parseCommandQuery(query.value)
760
+ if (terms.include.length) return []
761
+ const found = resolveRecentItems(recentList.value, props.commands, {
762
+ limit: Number.POSITIVE_INFINITY,
763
+ resolve: props.resolveRecent,
764
+ fromEntry,
765
+ })
560
766
  const out: Command[] = []
561
- for (const id of recentIds.value) {
562
- const c = byId.get(id)
563
- if (c && !c.disabled && !out.includes(c)) out.push(c)
767
+ for (const { command, path } of found) {
768
+ if (out.includes(command) || !recentVisible(command, terms, props.recentLabel)) continue
769
+ recentPath.set(command, path)
770
+ out.push(command)
564
771
  if (out.length >= props.recentLimit) break
565
772
  }
566
773
  return out
@@ -585,6 +792,17 @@ const grouped = computed<{ name: string; items: Command[] }[]>(() => {
585
792
  // the same row on screen twice with nothing to tell them apart.
586
793
  for (const c of ranked.value) if (!recent.includes(c)) push(c, c.group ?? "")
587
794
  for (const c of fetched.value) push(c, c.group ?? "")
795
+ // Last, whatever their group: they are the way out when nothing above is it.
796
+ const tail = new Map<string, Command[]>()
797
+ for (const c of fallbacks.value) {
798
+ const group = c.group ?? ""
799
+ if (map.has(group)) map.get(group)!.push(c)
800
+ else tail.set(group, [...(tail.get(group) ?? []), c])
801
+ }
802
+ for (const [group, items] of tail) {
803
+ map.set(group, items)
804
+ order.push(group)
805
+ }
588
806
  return order.map((name) => ({ name, items: folded(name, map.get(name)!) }))
589
807
  })
590
808
 
@@ -621,8 +839,14 @@ function folded(name: string, items: Command[]): Command[] {
621
839
  const flat = computed(() => grouped.value.flatMap((g) => g.items))
622
840
 
623
841
  const slots = defineSlots<{
624
- /** Body of the preview pane for the highlighted command. */
625
- preview?: (props: { command: Command }) => unknown
842
+ /**
843
+ * Body of the preview pane for the highlighted command. `highlight(text)`
844
+ * splits any text around the words typed (positive terms only, plus the
845
+ * command's own `highlight` words), the same matching the rows use; the
846
+ * LpPalette* blocks take its result wherever they take text, and
847
+ * `query` is the parsed box for anything else.
848
+ */
849
+ preview?: (props: { command: Command; highlight: (text: string) => HighlightPart[]; query: PaletteQuery }) => unknown
626
850
  }>()
627
851
 
628
852
  const active = computed<Command | undefined>(() => {
@@ -636,16 +860,55 @@ const previewRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
636
860
  * beside a list of commands is a column of dead space. With the slot the app
637
861
  * decides what to show, so any highlighted row opens it.
638
862
  */
639
- const paneOpen = computed(
640
- () => props.previewPane && !!active.value && (!!slots.preview || !!(active.value.preview || active.value.excerpt)),
863
+ const previewable = computed(() =>
864
+ props.previewPane && commandHasPreview(active.value, { slot: !!slots.preview, hasPreview: props.hasPreview }),
865
+ )
866
+
867
+ /*
868
+ * The row the pane shows. It follows the highlight while the highlight has a
869
+ * preview, and lingers for a moment when it lands on a row without one:
870
+ * arrowing through a list where every other row has a preview would
871
+ * otherwise widen and narrow the dialog on every keystroke. Only when the
872
+ * highlight stays on such rows does the pane fold.
873
+ */
874
+ const PANE_LINGER_MS = 160
875
+ const paneCommand = shallowRef<Command | undefined>()
876
+ let lingerTimer: ReturnType<typeof setTimeout> | undefined
877
+ watch(
878
+ [active, previewable],
879
+ ([command, show]) => {
880
+ clearTimeout(lingerTimer)
881
+ if (show) {
882
+ paneCommand.value = command
883
+ return
884
+ }
885
+ if (!paneCommand.value) return
886
+ lingerTimer = setTimeout(() => (paneCommand.value = undefined), PANE_LINGER_MS)
887
+ },
888
+ { immediate: true },
641
889
  )
890
+ onBeforeUnmount(() => clearTimeout(lingerTimer))
891
+
892
+ const paneOpen = computed(() => props.previewPane && !!paneCommand.value)
893
+
894
+ function paneHighlight(text: string): HighlightPart[] {
895
+ return highlight(text, paneBody.value?.highlight)
896
+ }
897
+ const paneQuery = computed(() => parsePaletteQuery(query.value.trim()))
898
+
899
+ // What the pane draws: the last row it showed, kept while the pane slides out
900
+ // so it leaves with its content instead of emptying first.
901
+ const paneBody = shallowRef<Command | undefined>()
902
+ watch(paneCommand, (command) => {
903
+ if (command) paneBody.value = command
904
+ })
642
905
 
643
906
  /*
644
907
  * The text in paragraphs, so they can arrive one after another. A blank line
645
908
  * separates them, as in the `preview` the consumer wrote.
646
909
  */
647
910
  const paragraphs = computed(() =>
648
- (active.value?.preview ?? active.value?.excerpt ?? "")
911
+ (paneBody.value?.preview ?? paneBody.value?.excerpt ?? "")
649
912
  .split(/\n{2,}/)
650
913
  .map((part) => part.trim())
651
914
  .filter(Boolean),
@@ -657,7 +920,7 @@ const paragraphs = computed(() =>
657
920
  * would otherwise print the same sentences twice, once small and once large.
658
921
  */
659
922
  const paneDescription = computed(() => {
660
- const description = active.value?.description?.trim()
923
+ const description = paneBody.value?.description?.trim()
661
924
  if (!description) return undefined
662
925
  const body = paragraphs.value.join(" ")
663
926
  return body.startsWith(description) ? undefined : description
@@ -673,7 +936,7 @@ const STAGGER_MAX = 10
673
936
  * from the top rather than wherever the previous result was scrolled to.
674
937
  */
675
938
  watch(
676
- () => active.value?.id,
939
+ () => paneCommand.value?.id,
677
940
  async () => {
678
941
  if (!paneOpen.value) return
679
942
  await nextTick()
@@ -692,46 +955,48 @@ function flatIndexOf(c: Command): number {
692
955
  }
693
956
 
694
957
  // ── highlight ────────────────────────────────────────────────
695
- // Split text around query terms (≥2 chars, longest-first) for <mark> wrapping,
696
- // without v-html. Multi-word queries highlight each term.
697
- interface Part {
698
- text: string
699
- hit: boolean
958
+ // Split text around the positive query terms (and a row's own `highlight`
959
+ // words) for <mark> wrapping, without v-html. Exclusions are never marked.
960
+ function highlight(value: string, extra: string[] = []): HighlightPart[] {
961
+ return highlightParts(value, [...local.value.terms, ...extra])
700
962
  }
701
- function highlight(value: string, extra: string[] = []): Part[] {
702
- const terms = [...new Set([...local.value.terms, ...extra.map((t) => t.toLowerCase())].filter((t) => t.length >= 2))].sort(
703
- (a, b) => b.length - a.length,
704
- )
705
- if (!value || terms.length === 0) return value ? [{ text: value, hit: false }] : []
706
- const lower = value.toLowerCase()
707
- const parts: Part[] = []
708
- let i = 0
709
- while (i < value.length) {
710
- let at = -1
711
- let term = ""
712
- for (const t of terms) {
713
- const found = lower.indexOf(t, i)
714
- if (found !== -1 && (at === -1 || found < at || (found === at && t.length > term.length))) {
715
- at = found
716
- term = t
717
- }
718
- }
719
- if (at === -1) {
720
- parts.push({ text: value.slice(i), hit: false })
721
- break
722
- }
723
- if (at > i) parts.push({ text: value.slice(i, at), hit: false })
724
- parts.push({ text: value.slice(at, at + term.length), hit: true })
725
- i = at + term.length
726
- }
727
- return parts
963
+
964
+ // ── row picture ──────────────────────────────────────────────
965
+ /*
966
+ * A string is shorthand for the src. A picture with nothing to fall back on
967
+ * borrows the command's icon, so a row that had an icon keeps one while the
968
+ * picture loads or after it fails.
969
+ */
970
+ // The avatar's own radii are drawn for 36px and up; at the icon's size a card
971
+ // radius rounds a square into a blob. Whole literals so Tailwind sees them.
972
+ const IMAGE_RADIUS = { circle: "rounded-pill", soft: "rounded-[6px]", square: "rounded-[3px]" } as const
973
+
974
+ function imageOf(c: Command): CommandImage | null {
975
+ if (!c.image) return null
976
+ const image = typeof c.image === "string" ? { src: c.image } : c.image
977
+ return { ...image, icon: image.icon ?? c.icon }
728
978
  }
729
979
 
730
980
  // ── selection + keyboard ─────────────────────────────────────
731
- watch(query, () => {
981
+ watch(query, (value) => {
982
+ if (enterByPrefix(value)) return
732
983
  select(0)
733
984
  refresh()
734
985
  })
986
+
987
+ /*
988
+ * A prefix typed (or pasted) at the start of the box opens its mode with the
989
+ * rest of the text as the query there. The list it leaves is remembered with
990
+ * an empty query: Backspace out of the mode should land on the plain list,
991
+ * not on the prefix, which would open the mode again on the spot.
992
+ */
993
+ function enterByPrefix(value: string): boolean {
994
+ const hit = matchModePrefix(value, source.value)
995
+ if (!hit) return false
996
+ enter(hit.command, "")
997
+ query.value = hit.rest
998
+ return true
999
+ }
735
1000
  watch([query, trail, () => props.open], () => {
736
1001
  expanded.value = new Set()
737
1002
  })
@@ -807,6 +1072,7 @@ function openInNewTab(c: Command) {
807
1072
  if (!c.href) return
808
1073
  window.open(c.href, "_blank", "noopener")
809
1074
  emit("run", c.id, c)
1075
+ remember(c)
810
1076
  emit("update:open", false)
811
1077
  }
812
1078
 
@@ -821,13 +1087,14 @@ function onRowClick(event: MouseEvent, c: Command) {
821
1087
  run(c)
822
1088
  }
823
1089
 
824
- function run(c: Command | undefined) {
825
- if (!c || c.disabled) return
1090
+ function run(row: Command | undefined) {
1091
+ if (!row || row.disabled) return
1092
+ const c = fallbackOf.get(row) ?? row
826
1093
  // The highlight stays on the same index, which is now the first row that
827
1094
  // was hidden: the arrows carry on from where the fold was.
828
- const group = moreOf.get(c)
1095
+ const group = moreOf.get(row)
829
1096
  if (group !== undefined) {
830
- selectedId.value = firstHidden.get(c) ?? null
1097
+ selectedId.value = firstHidden.get(row) ?? null
831
1098
  expanded.value = new Set([...expanded.value, group])
832
1099
  inputRef.value?.focus()
833
1100
  return
@@ -838,11 +1105,13 @@ function run(c: Command | undefined) {
838
1105
  // A command with children drills in instead of closing, and is navigation
839
1106
  // rather than a pick — so it stays out of the recency history.
840
1107
  if (c.children) {
1108
+ const carried = query.value
841
1109
  enter(c)
1110
+ if (c.carryQuery) query.value = carried
842
1111
  return
843
1112
  }
844
1113
  emit("run", c.id, c)
845
- if (!mode.value) remember(c.id)
1114
+ remember(c)
846
1115
  if (c.keepOpen) {
847
1116
  // Back to the top of a fresh list: the query that found this command is
848
1117
  // spent, and leaving it would hide whatever the action just changed.
@@ -975,7 +1244,7 @@ useHotkeys(() =>
975
1244
  @click="popTo(i)"
976
1245
  >
977
1246
  <LpIcon v-if="step.icon" :name="step.icon" :size="13" />
978
- {{ step.label }}
1247
+ {{ modeChipLabel(step) }}
979
1248
  <LpIcon name="lucide:x" :size="12" class="opacity-60" />
980
1249
  </button>
981
1250
 
@@ -1048,7 +1317,21 @@ useHotkeys(() =>
1048
1317
  @click="onRowClick($event, c)"
1049
1318
  @pointerenter="onPointerEnter(c)"
1050
1319
  >
1051
- <LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" :class="{ 'mt-0.5': c.excerpt }" />
1320
+ <!-- A picture takes the icon's slot at the icon's footprint, with
1321
+ a negative margin so a 20px avatar does not make its row
1322
+ taller than its neighbours. -->
1323
+ <LpAvatar
1324
+ v-if="imageOf(c)"
1325
+ :src="imageOf(c)!.src ?? undefined"
1326
+ :alt="imageOf(c)!.alt ?? ''"
1327
+ :fallback="imageOf(c)!.fallback"
1328
+ :icon="imageOf(c)!.icon"
1329
+ :shape="imageOf(c)!.shape ?? 'soft'"
1330
+ :pixel-size="20"
1331
+ class="-my-0.5 -ml-0.5 shrink-0 text-[9px]"
1332
+ :class="IMAGE_RADIUS[imageOf(c)!.shape ?? 'soft']"
1333
+ />
1334
+ <LpIcon v-else-if="c.icon" :name="c.icon" :size="16" class="shrink-0" :class="{ 'mt-0.5': c.excerpt }" />
1052
1335
  <span class="min-w-0 flex-1">
1053
1336
  <span class="block truncate font-medium">
1054
1337
  <template v-for="(p, pi) in highlight(c.label, c.highlight)" :key="`l${pi}`">
@@ -1078,6 +1361,12 @@ useHotkeys(() =>
1078
1361
  </span>
1079
1362
  </span>
1080
1363
  <kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
1364
+ <!-- The prefix that opens this mode from the box, so the
1365
+ shortcut is learned by seeing it rather than reading docs. -->
1366
+ <kbd
1367
+ v-else-if="c.prefix && c.children"
1368
+ class="shrink-0 rounded border border-line px-1.5 font-mono text-[11px] whitespace-pre text-muted"
1369
+ >{{ c.prefix }}</kbd>
1081
1370
  <!-- Drills in rather than runs: worth saying before the click,
1082
1371
  since the two look identical otherwise. -->
1083
1372
  <LpIcon
@@ -1118,11 +1407,22 @@ useHotkeys(() =>
1118
1407
  <LpScrollArea ref="previewRef" class="min-h-0 flex-1" content-class="px-5 py-4">
1119
1408
  <!-- Keyed by the row, so moving the highlight replays the
1120
1409
  entrance rather than swapping text in place. -->
1121
- <div v-if="active" :key="active.id">
1122
- <slot name="preview" :command="active">
1410
+ <div v-if="paneBody" :key="paneBody.id">
1411
+ <slot name="preview" :command="paneBody" :highlight="paneHighlight" :query="paneQuery">
1123
1412
  <div class="lp-cmd-para flex items-center gap-2 text-ink">
1124
- <LpIcon v-if="active.icon" :name="active.icon" :size="16" class="shrink-0 text-muted" />
1125
- <h3 class="min-w-0 text-base font-semibold">{{ active.label }}</h3>
1413
+ <LpAvatar
1414
+ v-if="imageOf(paneBody)"
1415
+ :src="imageOf(paneBody)!.src ?? undefined"
1416
+ :alt="imageOf(paneBody)!.alt ?? ''"
1417
+ :fallback="imageOf(paneBody)!.fallback"
1418
+ :icon="imageOf(paneBody)!.icon"
1419
+ :shape="imageOf(paneBody)!.shape ?? 'soft'"
1420
+ :pixel-size="28"
1421
+ class="shrink-0"
1422
+ :class="IMAGE_RADIUS[imageOf(paneBody)!.shape ?? 'soft']"
1423
+ />
1424
+ <LpIcon v-else-if="paneBody.icon" :name="paneBody.icon" :size="16" class="shrink-0 text-muted" />
1425
+ <h3 class="min-w-0 text-base font-semibold">{{ paneBody.label }}</h3>
1126
1426
  </div>
1127
1427
  <p v-if="paneDescription" class="lp-cmd-para mt-0.5 text-xs text-muted">
1128
1428
  {{ paneDescription }}
@@ -1133,7 +1433,7 @@ useHotkeys(() =>
1133
1433
  class="lp-cmd-para mt-3 whitespace-pre-line text-sm leading-relaxed text-ink/90"
1134
1434
  :style="{ animationDelay: `${Math.min(i + 1, STAGGER_MAX) * STAGGER_MS}ms` }"
1135
1435
  >
1136
- <template v-for="(p, pi) in highlight(para, active.highlight)" :key="`p${pi}`">
1436
+ <template v-for="(p, pi) in highlight(para, paneBody.highlight)" :key="`p${pi}`">
1137
1437
  <mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
1138
1438
  <template v-else>{{ p.text }}</template>
1139
1439
  </template>