arkaos 3.70.9 → 3.71.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.
- package/VERSION +1 -1
- package/core/personas/__pycache__/obsidian_store.cpython-313.pyc +0 -0
- package/core/personas/obsidian_store.py +7 -0
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +14 -0
|
@@ -1,228 +1,21 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// v3.71.0 — the terminal now lives in the app-wide dock (TerminalDock.vue,
|
|
3
|
+
// mounted in the default layout). This route simply opens the dock
|
|
4
|
+
// maximized, so /terminal stays a valid deep link and the sidebar entry
|
|
5
|
+
// keeps working. Leaving the route restores the dock to its docked size
|
|
6
|
+
// so it doesn't cover other pages.
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
import { useTerminalThemes } from '~/composables/useTerminalThemes'
|
|
8
|
+
import { useTerminalDock } from '~/composables/useTerminalDock'
|
|
9
9
|
|
|
10
10
|
definePageMeta({ layout: 'default' })
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
tabs,
|
|
14
|
-
activeId,
|
|
15
|
-
activeTab,
|
|
16
|
-
capReached,
|
|
17
|
-
maxTabs,
|
|
18
|
-
newTab,
|
|
19
|
-
closeTab,
|
|
20
|
-
switchTab,
|
|
21
|
-
renameTab,
|
|
22
|
-
} = useTerminalTabs()
|
|
12
|
+
const dock = useTerminalDock()
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
const HISTORY_MAX = 500
|
|
14
|
+
onMounted(() => dock.open({ maximized: true }))
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cmd: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// v3.70.3 — sanitise legacy entries polluted by ANSI ESC sequences
|
|
33
|
-
// that leaked through the v3.69.0 line-buffer before the proper
|
|
34
|
-
// state-machine filter landed.
|
|
35
|
-
function isPlausibleCommand(cmd: string): boolean {
|
|
36
|
-
if (!cmd || cmd.length < 2) return false
|
|
37
|
-
// Reject anything that looks like a CSI/SS3 remnant
|
|
38
|
-
if (/^\[?\?/.test(cmd)) return false
|
|
39
|
-
if (/\[[\d;?]*[A-Za-z~]/.test(cmd)) return false
|
|
40
|
-
// Reject anything starting with `[` followed by digits or letter — ESC remnant
|
|
41
|
-
if (/^\[[\dA-Za-z]/.test(cmd)) return false
|
|
42
|
-
// Must contain at least one alphanumeric — pure punctuation is suspect
|
|
43
|
-
if (!/[A-Za-z0-9]/.test(cmd)) return false
|
|
44
|
-
return true
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function loadHistory(): HistoryEntry[] {
|
|
48
|
-
if (typeof localStorage === 'undefined') return []
|
|
49
|
-
try {
|
|
50
|
-
const raw = localStorage.getItem(HISTORY_KEY)
|
|
51
|
-
if (!raw) return []
|
|
52
|
-
const parsed = JSON.parse(raw) as HistoryEntry[]
|
|
53
|
-
return parsed.filter((e) => e && typeof e.cmd === 'string' && isPlausibleCommand(e.cmd))
|
|
54
|
-
} catch {
|
|
55
|
-
return []
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const history = ref<HistoryEntry[]>(loadHistory())
|
|
60
|
-
|
|
61
|
-
function clearHistory() {
|
|
62
|
-
history.value = []
|
|
63
|
-
try {
|
|
64
|
-
localStorage.removeItem(HISTORY_KEY)
|
|
65
|
-
} catch {
|
|
66
|
-
// ignore
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function recordCommand(cmd: string) {
|
|
71
|
-
const trimmed = cmd.trim()
|
|
72
|
-
if (!isPlausibleCommand(trimmed)) return
|
|
73
|
-
history.value.unshift({ ts: Date.now(), cmd: trimmed })
|
|
74
|
-
if (history.value.length > HISTORY_MAX) {
|
|
75
|
-
history.value = history.value.slice(0, HISTORY_MAX)
|
|
76
|
-
}
|
|
77
|
-
try {
|
|
78
|
-
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
|
|
79
|
-
} catch {
|
|
80
|
-
// quota — silently truncate further
|
|
81
|
-
history.value = history.value.slice(0, 200)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// PR99d v3.70.0 — theme picker + Ctrl+R history search.
|
|
86
|
-
// v3.70.3 — proper command palette UX (keyboard nav, selected row).
|
|
87
|
-
const { themeName, setTheme, options: themeOptions } = useTerminalThemes()
|
|
88
|
-
const searchOpen = ref(false)
|
|
89
|
-
const searchQuery = ref('')
|
|
90
|
-
const searchSelectedIdx = ref(0)
|
|
91
|
-
|
|
92
|
-
const searchResults = computed(() => {
|
|
93
|
-
const q = searchQuery.value.trim().toLowerCase()
|
|
94
|
-
if (!q) return history.value.slice(0, 30)
|
|
95
|
-
return history.value
|
|
96
|
-
.filter((h) => h.cmd.toLowerCase().includes(q))
|
|
97
|
-
.slice(0, 30)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
watch(searchResults, () => {
|
|
101
|
-
searchSelectedIdx.value = 0
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
const searchInputEl = ref<HTMLInputElement | null>(null)
|
|
105
|
-
|
|
106
|
-
function openSearch() {
|
|
107
|
-
searchOpen.value = true
|
|
108
|
-
searchQuery.value = ''
|
|
109
|
-
searchSelectedIdx.value = 0
|
|
110
|
-
// autofocus on the bare input only fires on initial mount; the modal
|
|
111
|
-
// is mounted persistently, so we focus explicitly each time it opens.
|
|
112
|
-
nextTick(() => {
|
|
113
|
-
requestAnimationFrame(() => searchInputEl.value?.focus())
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function pickFromSearch(cmd: string) {
|
|
118
|
-
activeTab.value?.session.sendInput(cmd)
|
|
119
|
-
searchOpen.value = false
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// v3.70.4 — inline filter for the side panel.
|
|
123
|
-
const sidebarFilter = ref('')
|
|
124
|
-
|
|
125
|
-
const visibleHistory = computed(() => {
|
|
126
|
-
const q = sidebarFilter.value.trim().toLowerCase()
|
|
127
|
-
const filtered = history.value.filter((e) => isPlausibleCommand(e.cmd))
|
|
128
|
-
if (!q) return filtered
|
|
129
|
-
return filtered.filter((e) => e.cmd.toLowerCase().includes(q))
|
|
16
|
+
onBeforeUnmount(() => {
|
|
17
|
+
if (dock.isMaximized.value) dock.toggleMaximize()
|
|
130
18
|
})
|
|
131
|
-
|
|
132
|
-
function sendToActive(cmd: string) {
|
|
133
|
-
activeTab.value?.session.sendInput(cmd)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function searchKeydown(e: KeyboardEvent) {
|
|
137
|
-
const total = searchResults.value.length
|
|
138
|
-
if (total === 0) return
|
|
139
|
-
if (e.key === 'ArrowDown') {
|
|
140
|
-
e.preventDefault()
|
|
141
|
-
searchSelectedIdx.value = (searchSelectedIdx.value + 1) % total
|
|
142
|
-
} else if (e.key === 'ArrowUp') {
|
|
143
|
-
e.preventDefault()
|
|
144
|
-
searchSelectedIdx.value = (searchSelectedIdx.value - 1 + total) % total
|
|
145
|
-
} else if (e.key === 'Enter') {
|
|
146
|
-
e.preventDefault()
|
|
147
|
-
const chosen = searchResults.value[searchSelectedIdx.value]
|
|
148
|
-
if (chosen) pickFromSearch(chosen.cmd)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function relativeTime(ts: number): string {
|
|
153
|
-
const diff = (Date.now() - ts) / 1000
|
|
154
|
-
if (diff < 60) return 'just now'
|
|
155
|
-
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
156
|
-
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
157
|
-
return `${Math.floor(diff / 86400)}d ago`
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const editingTabId = ref<string | null>(null)
|
|
161
|
-
const renameDraft = ref('')
|
|
162
|
-
|
|
163
|
-
function startRename(tabId: string, currentTitle: string) {
|
|
164
|
-
editingTabId.value = tabId
|
|
165
|
-
renameDraft.value = currentTitle
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function commitRename() {
|
|
169
|
-
if (editingTabId.value) {
|
|
170
|
-
renameTab(editingTabId.value, renameDraft.value)
|
|
171
|
-
}
|
|
172
|
-
editingTabId.value = null
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const toast = useToast()
|
|
176
|
-
|
|
177
|
-
function tryNewTab() {
|
|
178
|
-
if (capReached.value) {
|
|
179
|
-
toast.add({
|
|
180
|
-
title: 'Maximum sessions reached',
|
|
181
|
-
description: `You can have up to ${maxTabs} sessions open at once. Close one to open a new one.`,
|
|
182
|
-
color: 'warning',
|
|
183
|
-
icon: 'i-lucide-alert-triangle',
|
|
184
|
-
})
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
newTab()
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Keyboard shortcuts.
|
|
191
|
-
defineShortcuts({
|
|
192
|
-
meta_t: { handler: tryNewTab, usingInput: false },
|
|
193
|
-
meta_w: {
|
|
194
|
-
handler: () => {
|
|
195
|
-
if (activeId.value) closeTab(activeId.value)
|
|
196
|
-
},
|
|
197
|
-
usingInput: false,
|
|
198
|
-
},
|
|
199
|
-
ctrl_r: { handler: openSearch, usingInput: false },
|
|
200
|
-
meta_1: { handler: () => switchByIndex(0), usingInput: false },
|
|
201
|
-
meta_2: { handler: () => switchByIndex(1), usingInput: false },
|
|
202
|
-
meta_3: { handler: () => switchByIndex(2), usingInput: false },
|
|
203
|
-
meta_4: { handler: () => switchByIndex(3), usingInput: false },
|
|
204
|
-
meta_5: { handler: () => switchByIndex(4), usingInput: false },
|
|
205
|
-
meta_6: { handler: () => switchByIndex(5), usingInput: false },
|
|
206
|
-
meta_7: { handler: () => switchByIndex(6), usingInput: false },
|
|
207
|
-
meta_8: { handler: () => switchByIndex(7), usingInput: false },
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
function switchByIndex(idx: number) {
|
|
211
|
-
const t = tabs.value[idx]
|
|
212
|
-
if (t) switchTab(t.id)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// First-visit: open one tab automatically.
|
|
216
|
-
onMounted(() => {
|
|
217
|
-
if (tabs.value.length === 0) newTab()
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
onBeforeUnmount(async () => {
|
|
221
|
-
// Don't proactively close tabs — operator may navigate back. Backend
|
|
222
|
-
// reaper will GC after the idle timeout (30 min).
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
const showHistory = ref(false)
|
|
226
19
|
</script>
|
|
227
20
|
|
|
228
21
|
<template>
|
|
@@ -232,327 +25,28 @@ const showHistory = ref(false)
|
|
|
232
25
|
<template #leading>
|
|
233
26
|
<UDashboardSidebarCollapse />
|
|
234
27
|
</template>
|
|
235
|
-
<template #right>
|
|
236
|
-
<div class="flex items-center gap-2">
|
|
237
|
-
<UBadge color="warning" variant="soft" size="sm">
|
|
238
|
-
<UIcon name="i-lucide-shield" class="size-3 mr-1" />
|
|
239
|
-
localhost only
|
|
240
|
-
</UBadge>
|
|
241
|
-
<USelect
|
|
242
|
-
:model-value="themeName"
|
|
243
|
-
:items="themeOptions"
|
|
244
|
-
size="xs"
|
|
245
|
-
class="w-40"
|
|
246
|
-
@update:model-value="setTheme($event as string)"
|
|
247
|
-
/>
|
|
248
|
-
<UButton
|
|
249
|
-
size="xs"
|
|
250
|
-
variant="ghost"
|
|
251
|
-
icon="i-lucide-search"
|
|
252
|
-
title="Ctrl+R — search history"
|
|
253
|
-
@click="openSearch"
|
|
254
|
-
>
|
|
255
|
-
⌃R
|
|
256
|
-
</UButton>
|
|
257
|
-
<UButton
|
|
258
|
-
size="xs"
|
|
259
|
-
variant="ghost"
|
|
260
|
-
:icon="showHistory ? 'i-lucide-x' : 'i-lucide-history'"
|
|
261
|
-
@click="showHistory = !showHistory"
|
|
262
|
-
>
|
|
263
|
-
History ({{ history.length }})
|
|
264
|
-
</UButton>
|
|
265
|
-
</div>
|
|
266
|
-
</template>
|
|
267
28
|
</UDashboardNavbar>
|
|
268
29
|
</template>
|
|
269
30
|
|
|
270
31
|
<template #body>
|
|
271
|
-
<div class="
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
: 'border-transparent text-muted hover:text-default hover:bg-elevated/30',
|
|
285
|
-
]"
|
|
286
|
-
@click="switchTab(tab.id)"
|
|
287
|
-
@dblclick="startRename(tab.id, tab.title)"
|
|
288
|
-
>
|
|
289
|
-
<span class="text-xs text-muted">{{ idx + 1 }}</span>
|
|
290
|
-
<UInput
|
|
291
|
-
v-if="editingTabId === tab.id"
|
|
292
|
-
v-model="renameDraft"
|
|
293
|
-
size="xs"
|
|
294
|
-
autofocus
|
|
295
|
-
@keydown.enter="commitRename"
|
|
296
|
-
@keydown.esc="editingTabId = null"
|
|
297
|
-
@blur="commitRename"
|
|
298
|
-
/>
|
|
299
|
-
<span v-else>{{ tab.title }}</span>
|
|
300
|
-
<UIcon
|
|
301
|
-
v-if="tab.hasActivity && activeId !== tab.id"
|
|
302
|
-
name="i-lucide-circle"
|
|
303
|
-
class="size-2 text-amber-400 fill-current"
|
|
304
|
-
/>
|
|
305
|
-
<button
|
|
306
|
-
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"
|
|
307
|
-
@click.stop="closeTab(tab.id)"
|
|
308
|
-
>
|
|
309
|
-
<UIcon name="i-lucide-x" class="size-3" />
|
|
310
|
-
</button>
|
|
311
|
-
</div>
|
|
312
|
-
<UButton
|
|
313
|
-
size="xs"
|
|
314
|
-
variant="ghost"
|
|
315
|
-
icon="i-lucide-plus"
|
|
316
|
-
:disabled="capReached"
|
|
317
|
-
:title="capReached ? `Max ${maxTabs} sessions` : 'New session'"
|
|
318
|
-
@click="tryNewTab"
|
|
319
|
-
>
|
|
320
|
-
New
|
|
321
|
-
</UButton>
|
|
322
|
-
</div>
|
|
323
|
-
|
|
324
|
-
<div class="flex-1 min-h-[480px] flex gap-3">
|
|
325
|
-
<div class="flex-1 relative">
|
|
326
|
-
<template v-for="tab in tabs" :key="tab.id">
|
|
327
|
-
<Terminal
|
|
328
|
-
v-show="activeId === tab.id"
|
|
329
|
-
:session="tab.session"
|
|
330
|
-
:on-input-line="recordCommand"
|
|
331
|
-
:active="activeId === tab.id"
|
|
332
|
-
class="absolute inset-0"
|
|
333
|
-
/>
|
|
334
|
-
</template>
|
|
335
|
-
<div
|
|
336
|
-
v-if="tabs.length === 0"
|
|
337
|
-
class="absolute inset-0 grid place-items-center text-muted text-sm"
|
|
338
|
-
>
|
|
339
|
-
No active sessions. Press ⌘T or click "+ New" to open one.
|
|
340
|
-
</div>
|
|
341
|
-
</div>
|
|
342
|
-
<aside
|
|
343
|
-
v-if="showHistory"
|
|
344
|
-
class="w-80 shrink-0 rounded-lg border border-default bg-elevated/10 overflow-hidden flex flex-col"
|
|
345
|
-
>
|
|
346
|
-
<div class="px-3 py-2.5 border-b border-default flex items-center gap-2">
|
|
347
|
-
<UIcon name="i-lucide-history" class="size-4 text-muted shrink-0" />
|
|
348
|
-
<span class="text-sm font-semibold">History</span>
|
|
349
|
-
<UBadge :label="String(visibleHistory.length)" size="xs" variant="subtle" />
|
|
350
|
-
<div class="ml-auto flex items-center gap-1">
|
|
351
|
-
<UButton
|
|
352
|
-
size="xs"
|
|
353
|
-
variant="ghost"
|
|
354
|
-
icon="i-lucide-search"
|
|
355
|
-
title="Open full search (⌃R)"
|
|
356
|
-
@click="openSearch"
|
|
357
|
-
/>
|
|
358
|
-
<UButton
|
|
359
|
-
v-if="history.length > 0"
|
|
360
|
-
size="xs"
|
|
361
|
-
variant="ghost"
|
|
362
|
-
color="error"
|
|
363
|
-
icon="i-lucide-trash-2"
|
|
364
|
-
title="Clear all"
|
|
365
|
-
@click="clearHistory"
|
|
366
|
-
/>
|
|
367
|
-
<UButton
|
|
368
|
-
size="xs"
|
|
369
|
-
variant="ghost"
|
|
370
|
-
icon="i-lucide-x"
|
|
371
|
-
title="Close panel"
|
|
372
|
-
@click="showHistory = false"
|
|
373
|
-
/>
|
|
374
|
-
</div>
|
|
375
|
-
</div>
|
|
376
|
-
|
|
377
|
-
<div class="px-3 py-2 border-b border-default">
|
|
378
|
-
<UInput
|
|
379
|
-
v-model="sidebarFilter"
|
|
380
|
-
size="xs"
|
|
381
|
-
placeholder="Filter…"
|
|
382
|
-
icon="i-lucide-search"
|
|
383
|
-
class="w-full"
|
|
384
|
-
/>
|
|
385
|
-
</div>
|
|
386
|
-
|
|
387
|
-
<div class="flex-1 overflow-y-auto">
|
|
388
|
-
<div
|
|
389
|
-
v-if="history.length === 0"
|
|
390
|
-
class="p-6 text-center text-xs text-muted"
|
|
32
|
+
<div class="h-full grid place-items-center text-center text-muted gap-3 p-8">
|
|
33
|
+
<div>
|
|
34
|
+
<UIcon name="i-lucide-terminal" class="size-10 mx-auto mb-3 opacity-40" />
|
|
35
|
+
<p class="text-sm">
|
|
36
|
+
The terminal runs in the dock — available on every page.
|
|
37
|
+
</p>
|
|
38
|
+
<p class="text-xs mt-1 opacity-70">
|
|
39
|
+
Toggle it anytime with ⌘J. Sessions survive navigation and reload.
|
|
40
|
+
</p>
|
|
41
|
+
<UButton
|
|
42
|
+
class="mt-4"
|
|
43
|
+
icon="i-lucide-terminal"
|
|
44
|
+
@click="dock.open({ maximized: true })"
|
|
391
45
|
>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
</div>
|
|
395
|
-
<div
|
|
396
|
-
v-else-if="visibleHistory.length === 0"
|
|
397
|
-
class="p-6 text-center text-xs text-muted"
|
|
398
|
-
>
|
|
399
|
-
No matches for
|
|
400
|
-
<span class="font-mono text-default">{{ sidebarFilter }}</span>.
|
|
401
|
-
</div>
|
|
402
|
-
<ul v-else class="py-1">
|
|
403
|
-
<li
|
|
404
|
-
v-for="entry in visibleHistory"
|
|
405
|
-
:key="entry.ts"
|
|
406
|
-
class="group mx-1 px-2.5 py-1 rounded-md cursor-pointer flex items-center gap-2 hover:bg-elevated/40 transition-colors"
|
|
407
|
-
:title="`${entry.cmd} — ${relativeTime(entry.ts)}`"
|
|
408
|
-
@click="sendToActive(entry.cmd)"
|
|
409
|
-
>
|
|
410
|
-
<span class="flex-1 min-w-0 font-mono text-xs truncate">
|
|
411
|
-
{{ entry.cmd }}
|
|
412
|
-
</span>
|
|
413
|
-
<span class="text-[10px] text-muted/70 shrink-0 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
|
414
|
-
{{ relativeTime(entry.ts) }}
|
|
415
|
-
</span>
|
|
416
|
-
<UIcon
|
|
417
|
-
name="i-lucide-corner-down-left"
|
|
418
|
-
class="size-3 shrink-0 text-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
|
419
|
-
/>
|
|
420
|
-
</li>
|
|
421
|
-
</ul>
|
|
422
|
-
</div>
|
|
423
|
-
|
|
424
|
-
<div class="px-3 py-2 border-t border-default text-[10px] text-muted">
|
|
425
|
-
Click a command to send it to the active session.
|
|
46
|
+
Open terminal
|
|
47
|
+
</UButton>
|
|
426
48
|
</div>
|
|
427
|
-
</aside>
|
|
428
|
-
</div>
|
|
429
|
-
|
|
430
|
-
<footer class="text-xs text-muted">
|
|
431
|
-
Sessions live on the backend until you close them or 30 min idle.
|
|
432
|
-
History stays in this browser only. Ctrl+R to search history.
|
|
433
|
-
</footer>
|
|
434
|
-
|
|
435
|
-
<UModal
|
|
436
|
-
v-model:open="searchOpen"
|
|
437
|
-
:ui="{ content: 'max-w-2xl ring-0 shadow-2xl' }"
|
|
438
|
-
>
|
|
439
|
-
<template #content>
|
|
440
|
-
<div class="rounded-xl bg-default overflow-hidden">
|
|
441
|
-
<div class="flex items-center gap-3 px-4 py-3 border-b border-default/60">
|
|
442
|
-
<UIcon name="i-lucide-history" class="size-4 text-muted shrink-0" />
|
|
443
|
-
<input
|
|
444
|
-
ref="searchInputEl"
|
|
445
|
-
v-model="searchQuery"
|
|
446
|
-
type="text"
|
|
447
|
-
autofocus
|
|
448
|
-
placeholder="Filter command history…"
|
|
449
|
-
class="palette-input flex-1 bg-transparent text-default placeholder:text-muted/70 focus:outline-none border-0 ring-0 text-sm"
|
|
450
|
-
@keydown="searchKeydown"
|
|
451
|
-
>
|
|
452
|
-
<span class="text-[11px] text-muted/70 shrink-0 tabular-nums">
|
|
453
|
-
{{ searchResults.length }} of {{ history.length }}
|
|
454
|
-
</span>
|
|
455
|
-
</div>
|
|
456
|
-
|
|
457
|
-
<div class="max-h-[60vh] overflow-y-auto">
|
|
458
|
-
<div
|
|
459
|
-
v-if="history.length === 0"
|
|
460
|
-
class="px-6 py-12 text-center text-sm text-muted"
|
|
461
|
-
>
|
|
462
|
-
<UIcon name="i-lucide-terminal" class="size-7 mx-auto mb-3 opacity-30" />
|
|
463
|
-
<p>No commands yet.</p>
|
|
464
|
-
<p class="text-xs mt-1 opacity-70">
|
|
465
|
-
Run something in the terminal — it'll show up here.
|
|
466
|
-
</p>
|
|
467
|
-
</div>
|
|
468
|
-
<div
|
|
469
|
-
v-else-if="searchResults.length === 0"
|
|
470
|
-
class="px-6 py-12 text-center text-sm text-muted"
|
|
471
|
-
>
|
|
472
|
-
No match for
|
|
473
|
-
<span class="font-mono text-default">{{ searchQuery }}</span>.
|
|
474
|
-
</div>
|
|
475
|
-
<ul v-else class="py-1">
|
|
476
|
-
<li
|
|
477
|
-
v-for="(entry, i) in searchResults"
|
|
478
|
-
:key="entry.ts"
|
|
479
|
-
class="mx-1 px-3 py-1.5 rounded-md cursor-pointer flex items-center gap-3 transition-colors"
|
|
480
|
-
:class="i === searchSelectedIdx
|
|
481
|
-
? 'bg-elevated/70'
|
|
482
|
-
: 'hover:bg-elevated/30'"
|
|
483
|
-
@click="pickFromSearch(entry.cmd)"
|
|
484
|
-
@mouseenter="searchSelectedIdx = i"
|
|
485
|
-
>
|
|
486
|
-
<span class="flex-1 min-w-0 font-mono text-sm truncate">
|
|
487
|
-
{{ entry.cmd }}
|
|
488
|
-
</span>
|
|
489
|
-
<span class="text-[11px] text-muted/70 shrink-0 tabular-nums">
|
|
490
|
-
{{ relativeTime(entry.ts) }}
|
|
491
|
-
</span>
|
|
492
|
-
<UIcon
|
|
493
|
-
name="i-lucide-corner-down-left"
|
|
494
|
-
class="size-3.5 shrink-0 transition-opacity"
|
|
495
|
-
:class="i === searchSelectedIdx ? 'text-default opacity-100' : 'text-muted opacity-0'"
|
|
496
|
-
/>
|
|
497
|
-
</li>
|
|
498
|
-
</ul>
|
|
499
|
-
</div>
|
|
500
|
-
|
|
501
|
-
<div class="px-4 py-2.5 border-t border-default/60 flex items-center gap-4 text-[11px] text-muted/80">
|
|
502
|
-
<span class="flex items-center gap-1.5">
|
|
503
|
-
<kbd class="palette-kbd">↑</kbd><kbd class="palette-kbd">↓</kbd>
|
|
504
|
-
navigate
|
|
505
|
-
</span>
|
|
506
|
-
<span class="flex items-center gap-1.5">
|
|
507
|
-
<kbd class="palette-kbd">↵</kbd>
|
|
508
|
-
send
|
|
509
|
-
</span>
|
|
510
|
-
<span class="flex items-center gap-1.5">
|
|
511
|
-
<kbd class="palette-kbd">esc</kbd>
|
|
512
|
-
close
|
|
513
|
-
</span>
|
|
514
|
-
<button
|
|
515
|
-
v-if="history.length > 0"
|
|
516
|
-
class="ml-auto text-muted/80 hover:text-red-400 transition-colors flex items-center gap-1"
|
|
517
|
-
@click="clearHistory"
|
|
518
|
-
>
|
|
519
|
-
<UIcon name="i-lucide-trash-2" class="size-3" />
|
|
520
|
-
Clear
|
|
521
|
-
</button>
|
|
522
|
-
</div>
|
|
523
|
-
</div>
|
|
524
|
-
</template>
|
|
525
|
-
</UModal>
|
|
526
49
|
</div>
|
|
527
50
|
</template>
|
|
528
51
|
</UDashboardPanel>
|
|
529
52
|
</template>
|
|
530
|
-
|
|
531
|
-
<style scoped>
|
|
532
|
-
.palette-input {
|
|
533
|
-
/* Defensive: kill any inherited ring/border from Tailwind base */
|
|
534
|
-
box-shadow: none !important;
|
|
535
|
-
outline: none !important;
|
|
536
|
-
}
|
|
537
|
-
.palette-input:focus,
|
|
538
|
-
.palette-input:focus-visible {
|
|
539
|
-
outline: none !important;
|
|
540
|
-
box-shadow: none !important;
|
|
541
|
-
border-color: transparent !important;
|
|
542
|
-
}
|
|
543
|
-
.palette-kbd {
|
|
544
|
-
display: inline-flex;
|
|
545
|
-
align-items: center;
|
|
546
|
-
justify-content: center;
|
|
547
|
-
min-width: 1.1rem;
|
|
548
|
-
padding: 0 0.3rem;
|
|
549
|
-
height: 1.1rem;
|
|
550
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
551
|
-
font-size: 10px;
|
|
552
|
-
line-height: 1;
|
|
553
|
-
border-radius: 4px;
|
|
554
|
-
background-color: rgb(var(--ui-bg-elevated) / 0.5);
|
|
555
|
-
color: rgb(var(--ui-text-muted));
|
|
556
|
-
border: 1px solid rgb(var(--ui-border) / 0.4);
|
|
557
|
-
}
|
|
558
|
-
</style>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -2365,6 +2365,20 @@ async def ws_terminal(ws: WebSocket, session_id: str, token: str = Query("")):
|
|
|
2365
2365
|
return
|
|
2366
2366
|
|
|
2367
2367
|
await ws.accept()
|
|
2368
|
+
|
|
2369
|
+
# v3.71.0 — replay recent scrollback so a client reconnecting after
|
|
2370
|
+
# the operator navigated away / reloaded restores its session as it
|
|
2371
|
+
# left it. Sent before the live reader is attached, so the historical
|
|
2372
|
+
# prefix always precedes any new output (no interleave, no dup — these
|
|
2373
|
+
# bytes were already consumed from the kernel buffer when first read).
|
|
2374
|
+
replay = session.scrollback()
|
|
2375
|
+
if replay:
|
|
2376
|
+
try:
|
|
2377
|
+
await ws.send_bytes(replay)
|
|
2378
|
+
except Exception:
|
|
2379
|
+
await ws.close(code=1011, reason="replay failed")
|
|
2380
|
+
return
|
|
2381
|
+
|
|
2368
2382
|
loop = asyncio.get_event_loop()
|
|
2369
2383
|
output_queue: asyncio.Queue = asyncio.Queue()
|
|
2370
2384
|
|