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.
- package/VERSION +1 -1
- package/core/personas/__pycache__/obsidian_store.cpython-313.pyc +0 -0
- package/core/terminal/__init__.py +2 -0
- package/core/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/connections.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/connections.py +46 -0
- package/core/terminal/session.py +28 -0
- package/dashboard/app/components/TerminalDock.vue +638 -0
- package/dashboard/app/composables/useTerminalDock.ts +88 -0
- package/dashboard/app/composables/useTerminalSession.ts +68 -29
- package/dashboard/app/composables/useTerminalTabs.ts +121 -46
- package/dashboard/app/layouts/default.vue +6 -0
- package/dashboard/app/pages/terminal.vue +25 -531
- package/dashboard/package.json +3 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +37 -4
|
@@ -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>
|