arkaos 3.70.10 → 3.71.1

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,638 @@
1
+ <script setup lang="ts">
2
+ // v3.71.0 — app-wide terminal dock. Mounted ONCE in the default layout
3
+ // (outside <NuxtPage>), so navigating between dashboard pages never
4
+ // unmounts it: the PTY WebSocket stays open and the xterm scrollback is
5
+ // preserved. Hidden with v-show (not v-if) so toggling the dock keeps
6
+ // the terminals mounted. On a full reload, reattachOnLoad() reconnects
7
+ // to the still-live backend sessions (which replay their scrollback).
8
+ //
9
+ // This component owns the command history, search palette, theme picker
10
+ // and keyboard shortcuts that used to live in pages/terminal.vue.
11
+
12
+ import { useTerminalTabs } from '~/composables/useTerminalTabs'
13
+ import { useTerminalThemes } from '~/composables/useTerminalThemes'
14
+ import { useTerminalDock } from '~/composables/useTerminalDock'
15
+
16
+ const {
17
+ tabs,
18
+ activeId,
19
+ activeSession,
20
+ capReached,
21
+ maxTabs,
22
+ sessionFor,
23
+ newTab,
24
+ closeTab,
25
+ switchTab,
26
+ renameTab,
27
+ reattachOnLoad
28
+ } = useTerminalTabs()
29
+
30
+ const {
31
+ isOpen,
32
+ isMaximized,
33
+ heightVh,
34
+ open: openDock,
35
+ close: closeDock,
36
+ toggle: toggleDock,
37
+ toggleMaximize,
38
+ setHeight
39
+ } = useTerminalDock()
40
+
41
+ // ─── Command history (browser-local) ─────────────────────────────────────
42
+ const HISTORY_KEY = 'arka-terminal-command-history'
43
+ const HISTORY_MAX = 500
44
+
45
+ interface HistoryEntry {
46
+ ts: number
47
+ cmd: string
48
+ }
49
+
50
+ function isPlausibleCommand(cmd: string): boolean {
51
+ if (!cmd || cmd.length < 2) return false
52
+ if (/^\[?\?/.test(cmd)) return false
53
+ if (/\[[\d;?]*[A-Za-z~]/.test(cmd)) return false
54
+ if (/^\[[\dA-Za-z]/.test(cmd)) return false
55
+ if (!/[A-Za-z0-9]/.test(cmd)) return false
56
+ return true
57
+ }
58
+
59
+ function loadHistory(): HistoryEntry[] {
60
+ if (typeof localStorage === 'undefined') return []
61
+ try {
62
+ const parsed = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]') as HistoryEntry[]
63
+ return parsed.filter(e => e && typeof e.cmd === 'string' && isPlausibleCommand(e.cmd))
64
+ } catch {
65
+ return []
66
+ }
67
+ }
68
+
69
+ const history = ref<HistoryEntry[]>(loadHistory())
70
+
71
+ function clearHistory() {
72
+ history.value = []
73
+ try {
74
+ localStorage.removeItem(HISTORY_KEY)
75
+ } catch {
76
+ // ignore
77
+ }
78
+ }
79
+
80
+ function recordCommand(cmd: string) {
81
+ const trimmed = cmd.trim()
82
+ if (!isPlausibleCommand(trimmed)) return
83
+ history.value.unshift({ ts: Date.now(), cmd: trimmed })
84
+ if (history.value.length > HISTORY_MAX) {
85
+ history.value = history.value.slice(0, HISTORY_MAX)
86
+ }
87
+ try {
88
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
89
+ } catch {
90
+ history.value = history.value.slice(0, 200)
91
+ }
92
+ }
93
+
94
+ // ─── Theme + search palette ──────────────────────────────────────────────
95
+ const { themeName, setTheme, options: themeOptions } = useTerminalThemes()
96
+ const searchOpen = ref(false)
97
+ const searchQuery = ref('')
98
+ const searchSelectedIdx = ref(0)
99
+
100
+ const searchResults = computed(() => {
101
+ const q = searchQuery.value.trim().toLowerCase()
102
+ if (!q) return history.value.slice(0, 30)
103
+ return history.value.filter(h => h.cmd.toLowerCase().includes(q)).slice(0, 30)
104
+ })
105
+
106
+ watch(searchResults, () => {
107
+ searchSelectedIdx.value = 0
108
+ })
109
+
110
+ const searchInputEl = ref<HTMLInputElement | null>(null)
111
+
112
+ function openSearch() {
113
+ searchOpen.value = true
114
+ searchQuery.value = ''
115
+ searchSelectedIdx.value = 0
116
+ nextTick(() => {
117
+ requestAnimationFrame(() => searchInputEl.value?.focus())
118
+ })
119
+ }
120
+
121
+ function pickFromSearch(cmd: string) {
122
+ activeSession.value?.sendInput(cmd)
123
+ searchOpen.value = false
124
+ }
125
+
126
+ const sidebarFilter = ref('')
127
+
128
+ const visibleHistory = computed(() => {
129
+ const q = sidebarFilter.value.trim().toLowerCase()
130
+ const filtered = history.value.filter(e => isPlausibleCommand(e.cmd))
131
+ if (!q) return filtered
132
+ return filtered.filter(e => e.cmd.toLowerCase().includes(q))
133
+ })
134
+
135
+ function sendToActive(cmd: string) {
136
+ activeSession.value?.sendInput(cmd)
137
+ }
138
+
139
+ function searchKeydown(e: KeyboardEvent) {
140
+ const total = searchResults.value.length
141
+ if (total === 0) return
142
+ if (e.key === 'ArrowDown') {
143
+ e.preventDefault()
144
+ searchSelectedIdx.value = (searchSelectedIdx.value + 1) % total
145
+ } else if (e.key === 'ArrowUp') {
146
+ e.preventDefault()
147
+ searchSelectedIdx.value = (searchSelectedIdx.value - 1 + total) % total
148
+ } else if (e.key === 'Enter') {
149
+ e.preventDefault()
150
+ const chosen = searchResults.value[searchSelectedIdx.value]
151
+ if (chosen) pickFromSearch(chosen.cmd)
152
+ }
153
+ }
154
+
155
+ function relativeTime(ts: number): string {
156
+ const diff = (Date.now() - ts) / 1000
157
+ if (diff < 60) return 'just now'
158
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
159
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
160
+ return `${Math.floor(diff / 86400)}d ago`
161
+ }
162
+
163
+ // ─── Tab rename ──────────────────────────────────────────────────────────
164
+ const editingTabId = ref<string | null>(null)
165
+ const renameDraft = ref('')
166
+
167
+ function startRename(tabId: string, currentTitle: string) {
168
+ editingTabId.value = tabId
169
+ renameDraft.value = currentTitle
170
+ }
171
+
172
+ function commitRename() {
173
+ if (editingTabId.value) renameTab(editingTabId.value, renameDraft.value)
174
+ editingTabId.value = null
175
+ }
176
+
177
+ // ─── New tab + cap toast ─────────────────────────────────────────────────
178
+ const toast = useToast()
179
+
180
+ function tryNewTab() {
181
+ if (capReached.value) {
182
+ toast.add({
183
+ title: 'Maximum sessions reached',
184
+ description: `You can have up to ${maxTabs} sessions open at once. Close one to open a new one.`,
185
+ color: 'warning',
186
+ icon: 'i-lucide-alert-triangle'
187
+ })
188
+ return
189
+ }
190
+ newTab()
191
+ }
192
+
193
+ const showHistory = ref(false)
194
+
195
+ // ─── Terminal refits (v-show + dock-open need explicit refit) ────────────
196
+ const termRefs = ref<Record<string, { refit?: () => void } | null>>({})
197
+
198
+ function setTermRef(id: string, el: unknown) {
199
+ if (el) termRefs.value[id] = el as { refit?: () => void }
200
+ else termRefs.value[id] = null
201
+ }
202
+
203
+ function refitActive() {
204
+ const id = activeId.value
205
+ if (!id) return
206
+ nextTick(() => {
207
+ requestAnimationFrame(() => termRefs.value[id]?.refit?.())
208
+ })
209
+ }
210
+
211
+ watch([isOpen, isMaximized, heightVh], () => {
212
+ if (isOpen.value) refitActive()
213
+ })
214
+
215
+ // ─── Keep the dock to the RIGHT of the sidebar so the menu stays visible
216
+ // and clickable even when the dock is maximized. The sidebar is resizable
217
+ // /collapsible, so track its right edge live. Falls back to 0 (full width)
218
+ // if the element can't be found.
219
+ const dockLeft = ref(0)
220
+ let sidebarRO: ResizeObserver | null = null
221
+
222
+ function trackSidebar() {
223
+ // Nuxt UI renders <UDashboardSidebar id="default"> as the DOM element
224
+ // #dashboard-sidebar-default. It's `hidden` below lg, where its rect
225
+ // collapses to 0 and the dock correctly spans full width.
226
+ const el = document.querySelector('#dashboard-sidebar-default') as HTMLElement | null
227
+ if (!el) return
228
+ const update = () => {
229
+ dockLeft.value = Math.max(0, el.getBoundingClientRect().right)
230
+ }
231
+ update()
232
+ sidebarRO = new ResizeObserver(update)
233
+ sidebarRO.observe(el)
234
+ window.addEventListener('resize', update)
235
+ }
236
+
237
+ // Opening the dock with no sessions spawns the first one. Covers both
238
+ // the toggle case (false -> true change) and the already-open-on-mount
239
+ // case (persisted state), handled in onMounted below.
240
+ function ensureSession() {
241
+ if (isOpen.value && tabs.value.length === 0) newTab()
242
+ }
243
+
244
+ watch(isOpen, ensureSession)
245
+
246
+ // ─── Resize drag (top edge) ──────────────────────────────────────────────
247
+ function startResize(e: PointerEvent) {
248
+ if (isMaximized.value) return
249
+ e.preventDefault()
250
+ const startY = e.clientY
251
+ const startH = heightVh.value
252
+ function onMove(ev: PointerEvent) {
253
+ const dy = startY - ev.clientY
254
+ setHeight(startH + (dy / window.innerHeight) * 100)
255
+ }
256
+ function onUp() {
257
+ window.removeEventListener('pointermove', onMove)
258
+ window.removeEventListener('pointerup', onUp)
259
+ }
260
+ window.addEventListener('pointermove', onMove)
261
+ window.addEventListener('pointerup', onUp)
262
+ }
263
+
264
+ // ─── Keyboard shortcuts ──────────────────────────────────────────────────
265
+ function switchByIndex(idx: number) {
266
+ const t = tabs.value[idx]
267
+ if (t) switchTab(t.id)
268
+ }
269
+
270
+ defineShortcuts({
271
+ // Cmd/Ctrl+J toggles the dock from anywhere in the dashboard.
272
+ meta_j: { handler: toggleDock, usingInput: false },
273
+ ctrl_j: { handler: toggleDock, usingInput: false },
274
+ // Session shortcuts only act while the dock is open.
275
+ meta_t: { handler: () => { if (isOpen.value) tryNewTab() }, usingInput: false },
276
+ meta_w: {
277
+ handler: () => {
278
+ if (isOpen.value && activeId.value) closeTab(activeId.value)
279
+ },
280
+ usingInput: false
281
+ },
282
+ ctrl_r: { handler: () => { if (isOpen.value) openSearch() }, usingInput: false },
283
+ meta_1: { handler: () => { if (isOpen.value) switchByIndex(0) }, usingInput: false },
284
+ meta_2: { handler: () => { if (isOpen.value) switchByIndex(1) }, usingInput: false },
285
+ meta_3: { handler: () => { if (isOpen.value) switchByIndex(2) }, usingInput: false },
286
+ meta_4: { handler: () => { if (isOpen.value) switchByIndex(3) }, usingInput: false },
287
+ meta_5: { handler: () => { if (isOpen.value) switchByIndex(4) }, usingInput: false },
288
+ meta_6: { handler: () => { if (isOpen.value) switchByIndex(5) }, usingInput: false },
289
+ meta_7: { handler: () => { if (isOpen.value) switchByIndex(6) }, usingInput: false },
290
+ meta_8: { handler: () => { if (isOpen.value) switchByIndex(7) }, usingInput: false }
291
+ })
292
+
293
+ // Reattach surviving sessions after a reload, then restore the dock if
294
+ // it was open (and reveal it whenever sessions came back).
295
+ onMounted(async () => {
296
+ trackSidebar()
297
+ await reattachOnLoad()
298
+ if (tabs.value.length > 0) openDock()
299
+ ensureSession()
300
+ })
301
+
302
+ onBeforeUnmount(() => {
303
+ sidebarRO?.disconnect()
304
+ })
305
+ </script>
306
+
307
+ <template>
308
+ <div>
309
+ <!-- Floating launcher when the dock is closed. v-if (not v-show)
310
+ because v-show is a directive that doesn't attach to a component
311
+ with a non-element root (UButton). -->
312
+ <UButton
313
+ v-if="!isOpen"
314
+ icon="i-lucide-terminal"
315
+ color="neutral"
316
+ variant="solid"
317
+ class="fixed bottom-4 right-4 z-40 shadow-lg"
318
+ aria-label="Open terminal (Cmd+J)"
319
+ title="Open terminal — ⌘J"
320
+ @click="openDock()"
321
+ >
322
+ Terminal
323
+ <UBadge
324
+ v-if="tabs.length"
325
+ :label="String(tabs.length)"
326
+ size="xs"
327
+ variant="subtle"
328
+ />
329
+ </UButton>
330
+
331
+ <!-- The dock. v-show (not v-if) so the xterm instances stay mounted. -->
332
+ <section
333
+ v-show="isOpen"
334
+ class="fixed right-0 bottom-0 z-40 flex flex-col bg-default border-t border-l border-default shadow-2xl"
335
+ :style="{ height: isMaximized ? '94vh' : `${heightVh}vh`, left: `${dockLeft}px` }"
336
+ role="region"
337
+ aria-label="Terminal dock"
338
+ >
339
+ <!-- Resize handle -->
340
+ <div
341
+ class="h-1.5 w-full cursor-row-resize bg-transparent hover:bg-primary/40 shrink-0"
342
+ :class="{ 'pointer-events-none': isMaximized }"
343
+ role="separator"
344
+ aria-orientation="horizontal"
345
+ aria-label="Resize terminal"
346
+ @pointerdown="startResize"
347
+ />
348
+
349
+ <!-- Header: tabs + controls -->
350
+ <header class="flex items-center gap-2 px-3 py-1.5 border-b border-default shrink-0">
351
+ <div class="flex items-center gap-1 overflow-x-auto flex-1 min-w-0">
352
+ <div
353
+ v-for="(tab, idx) in tabs"
354
+ :key="tab.id"
355
+ :class="[
356
+ 'group flex items-center gap-1 px-3 py-1 rounded-md cursor-pointer text-sm shrink-0 border transition-colors',
357
+ activeId === tab.id
358
+ ? 'bg-elevated/60 border-primary text-default'
359
+ : 'border-transparent text-muted hover:text-default hover:bg-elevated/30'
360
+ ]"
361
+ @click="switchTab(tab.id)"
362
+ @dblclick="startRename(tab.id, tab.title)"
363
+ >
364
+ <span class="text-xs text-muted">{{ idx + 1 }}</span>
365
+ <UInput
366
+ v-if="editingTabId === tab.id"
367
+ v-model="renameDraft"
368
+ size="xs"
369
+ autofocus
370
+ @keydown.enter="commitRename"
371
+ @keydown.esc="editingTabId = null"
372
+ @blur="commitRename"
373
+ />
374
+ <span v-else>{{ tab.title }}</span>
375
+ <UIcon
376
+ v-if="tab.hasActivity && activeId !== tab.id"
377
+ name="i-lucide-circle"
378
+ class="size-2 text-amber-400 fill-current"
379
+ />
380
+ <button
381
+ class="ml-1 size-4 grid place-items-center rounded text-muted hover:bg-default/50 hover:text-default opacity-0 group-hover:opacity-100"
382
+ :aria-label="`Close ${tab.title}`"
383
+ @click.stop="closeTab(tab.id)"
384
+ >
385
+ <UIcon name="i-lucide-x" class="size-3" />
386
+ </button>
387
+ </div>
388
+ <UButton
389
+ size="xs"
390
+ variant="ghost"
391
+ icon="i-lucide-plus"
392
+ :disabled="capReached"
393
+ :title="capReached ? `Max ${maxTabs} sessions` : 'New session'"
394
+ aria-label="New terminal session"
395
+ @click="tryNewTab"
396
+ >
397
+ New
398
+ </UButton>
399
+ </div>
400
+
401
+ <div class="flex items-center gap-1.5 shrink-0">
402
+ <UBadge
403
+ color="warning"
404
+ variant="soft"
405
+ size="sm"
406
+ class="hidden sm:flex"
407
+ >
408
+ <UIcon name="i-lucide-shield" class="size-3 mr-1" />
409
+ localhost only
410
+ </UBadge>
411
+ <USelect
412
+ :model-value="themeName"
413
+ :items="themeOptions"
414
+ size="xs"
415
+ class="w-36 hidden md:block"
416
+ aria-label="Terminal theme"
417
+ @update:model-value="setTheme($event as string)"
418
+ />
419
+ <UButton
420
+ size="xs"
421
+ variant="ghost"
422
+ icon="i-lucide-search"
423
+ title="Search history — ⌃R"
424
+ aria-label="Search command history"
425
+ @click="openSearch"
426
+ />
427
+ <UButton
428
+ size="xs"
429
+ variant="ghost"
430
+ :icon="showHistory ? 'i-lucide-panel-right-close' : 'i-lucide-history'"
431
+ :title="`History (${history.length})`"
432
+ aria-label="Toggle history panel"
433
+ @click="showHistory = !showHistory"
434
+ />
435
+ <UButton
436
+ size="xs"
437
+ variant="ghost"
438
+ :icon="isMaximized ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
439
+ :title="isMaximized ? 'Restore' : 'Maximize'"
440
+ aria-label="Toggle maximize"
441
+ @click="toggleMaximize"
442
+ />
443
+ <UButton
444
+ size="xs"
445
+ variant="ghost"
446
+ icon="i-lucide-chevron-down"
447
+ title="Close dock — ⌘J"
448
+ aria-label="Close terminal dock"
449
+ @click="closeDock"
450
+ />
451
+ </div>
452
+ </header>
453
+
454
+ <!-- Body: terminals + history sidebar -->
455
+ <div class="flex-1 min-h-0 flex gap-2 p-2">
456
+ <div class="flex-1 relative min-w-0">
457
+ <template v-for="tab in tabs" :key="tab.id">
458
+ <Terminal
459
+ v-show="activeId === tab.id"
460
+ :ref="el => setTermRef(tab.id, el)"
461
+ :session="sessionFor(tab.id)"
462
+ :on-input-line="recordCommand"
463
+ :active="activeId === tab.id && isOpen"
464
+ class="absolute inset-0"
465
+ />
466
+ </template>
467
+ <div
468
+ v-if="tabs.length === 0"
469
+ class="absolute inset-0 grid place-items-center text-muted text-sm"
470
+ >
471
+ No active sessions. Press ⌘T or click "+ New".
472
+ </div>
473
+ </div>
474
+
475
+ <aside
476
+ v-if="showHistory"
477
+ class="w-72 shrink-0 rounded-lg border border-default bg-elevated/10 overflow-hidden flex flex-col"
478
+ >
479
+ <div class="px-3 py-2 border-b border-default flex items-center gap-2">
480
+ <UIcon name="i-lucide-history" class="size-4 text-muted shrink-0" />
481
+ <span class="text-sm font-semibold">History</span>
482
+ <UBadge :label="String(visibleHistory.length)" size="xs" variant="subtle" />
483
+ <div class="ml-auto flex items-center gap-1">
484
+ <UButton
485
+ v-if="history.length > 0"
486
+ size="xs"
487
+ variant="ghost"
488
+ color="error"
489
+ icon="i-lucide-trash-2"
490
+ title="Clear all"
491
+ aria-label="Clear history"
492
+ @click="clearHistory"
493
+ />
494
+ </div>
495
+ </div>
496
+ <div class="px-3 py-2 border-b border-default">
497
+ <UInput
498
+ v-model="sidebarFilter"
499
+ size="xs"
500
+ placeholder="Filter…"
501
+ icon="i-lucide-search"
502
+ class="w-full"
503
+ aria-label="Filter history"
504
+ />
505
+ </div>
506
+ <div class="flex-1 overflow-y-auto">
507
+ <div v-if="history.length === 0" class="p-6 text-center text-xs text-muted">
508
+ <UIcon name="i-lucide-terminal" class="size-6 mx-auto mb-2 opacity-50" />
509
+ <p>No commands yet.</p>
510
+ </div>
511
+ <div
512
+ v-else-if="visibleHistory.length === 0"
513
+ class="p-6 text-center text-xs text-muted"
514
+ >
515
+ No matches for
516
+ <span class="font-mono text-default">{{ sidebarFilter }}</span>.
517
+ </div>
518
+ <ul v-else class="py-1">
519
+ <li
520
+ v-for="(entry, i) in visibleHistory"
521
+ :key="`${entry.ts}-${i}`"
522
+ class="group mx-1 px-2.5 py-1 rounded-md cursor-pointer flex items-center gap-2 hover:bg-elevated/40 transition-colors"
523
+ :title="`${entry.cmd} — ${relativeTime(entry.ts)}`"
524
+ @click="sendToActive(entry.cmd)"
525
+ >
526
+ <span class="flex-1 min-w-0 font-mono text-xs truncate">{{ entry.cmd }}</span>
527
+ <span class="text-[10px] text-muted/70 shrink-0 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
528
+ {{ relativeTime(entry.ts) }}
529
+ </span>
530
+ <UIcon
531
+ name="i-lucide-corner-down-left"
532
+ class="size-3 shrink-0 text-muted opacity-0 group-hover:opacity-100 transition-opacity"
533
+ />
534
+ </li>
535
+ </ul>
536
+ </div>
537
+ </aside>
538
+ </div>
539
+
540
+ <!-- Search palette -->
541
+ <UModal v-model:open="searchOpen" :ui="{ content: 'max-w-2xl ring-0 shadow-2xl' }">
542
+ <template #content>
543
+ <div class="rounded-xl bg-default overflow-hidden">
544
+ <div class="flex items-center gap-3 px-4 py-3 border-b border-default/60">
545
+ <UIcon name="i-lucide-history" class="size-4 text-muted shrink-0" />
546
+ <input
547
+ ref="searchInputEl"
548
+ v-model="searchQuery"
549
+ type="text"
550
+ autofocus
551
+ placeholder="Filter command history…"
552
+ aria-label="Search command history"
553
+ class="palette-input flex-1 bg-transparent text-default placeholder:text-muted/70 focus:outline-none border-0 ring-0 text-sm"
554
+ @keydown="searchKeydown"
555
+ >
556
+ <span class="text-[11px] text-muted/70 shrink-0 tabular-nums">
557
+ {{ searchResults.length }} of {{ history.length }}
558
+ </span>
559
+ </div>
560
+ <div class="max-h-[60vh] overflow-y-auto">
561
+ <div v-if="history.length === 0" class="px-6 py-12 text-center text-sm text-muted">
562
+ <UIcon name="i-lucide-terminal" class="size-7 mx-auto mb-3 opacity-30" />
563
+ <p>No commands yet.</p>
564
+ <p class="text-xs mt-1 opacity-70">
565
+ Run something in the terminal — it'll show up here.
566
+ </p>
567
+ </div>
568
+ <div
569
+ v-else-if="searchResults.length === 0"
570
+ class="px-6 py-12 text-center text-sm text-muted"
571
+ >
572
+ No match for <span class="font-mono text-default">{{ searchQuery }}</span>.
573
+ </div>
574
+ <ul v-else class="py-1">
575
+ <li
576
+ v-for="(entry, i) in searchResults"
577
+ :key="`${entry.ts}-${i}`"
578
+ class="mx-1 px-3 py-1.5 rounded-md cursor-pointer flex items-center gap-3 transition-colors"
579
+ :class="i === searchSelectedIdx ? 'bg-elevated/70' : 'hover:bg-elevated/30'"
580
+ @click="pickFromSearch(entry.cmd)"
581
+ @mouseenter="searchSelectedIdx = i"
582
+ >
583
+ <span class="flex-1 min-w-0 font-mono text-sm truncate">{{ entry.cmd }}</span>
584
+ <span class="text-[11px] text-muted/70 shrink-0 tabular-nums">{{ relativeTime(entry.ts) }}</span>
585
+ <UIcon
586
+ name="i-lucide-corner-down-left"
587
+ class="size-3.5 shrink-0 transition-opacity"
588
+ :class="i === searchSelectedIdx ? 'text-default opacity-100' : 'text-muted opacity-0'"
589
+ />
590
+ </li>
591
+ </ul>
592
+ </div>
593
+ <div class="px-4 py-2.5 border-t border-default/60 flex items-center gap-4 text-[11px] text-muted/80">
594
+ <span class="flex items-center gap-1.5"><kbd class="palette-kbd">↑</kbd><kbd class="palette-kbd">↓</kbd> navigate</span>
595
+ <span class="flex items-center gap-1.5"><kbd class="palette-kbd">↵</kbd> send</span>
596
+ <span class="flex items-center gap-1.5"><kbd class="palette-kbd">esc</kbd> close</span>
597
+ <button
598
+ v-if="history.length > 0"
599
+ class="ml-auto text-muted/80 hover:text-red-400 transition-colors flex items-center gap-1"
600
+ @click="clearHistory"
601
+ >
602
+ <UIcon name="i-lucide-trash-2" class="size-3" /> Clear
603
+ </button>
604
+ </div>
605
+ </div>
606
+ </template>
607
+ </UModal>
608
+ </section>
609
+ </div>
610
+ </template>
611
+
612
+ <style scoped>
613
+ .palette-input {
614
+ box-shadow: none !important;
615
+ outline: none !important;
616
+ }
617
+ .palette-input:focus,
618
+ .palette-input:focus-visible {
619
+ outline: none !important;
620
+ box-shadow: none !important;
621
+ border-color: transparent !important;
622
+ }
623
+ .palette-kbd {
624
+ display: inline-flex;
625
+ align-items: center;
626
+ justify-content: center;
627
+ min-width: 1.1rem;
628
+ padding: 0 0.3rem;
629
+ height: 1.1rem;
630
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
631
+ font-size: 10px;
632
+ line-height: 1;
633
+ border-radius: 4px;
634
+ background-color: rgb(var(--ui-bg-elevated) / 0.5);
635
+ color: rgb(var(--ui-text-muted));
636
+ border: 1px solid rgb(var(--ui-border) / 0.4);
637
+ }
638
+ </style>