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.
@@ -1,228 +1,21 @@
1
1
  <script setup lang="ts">
2
- // PR99b v3.68.0 — Real-shell terminal (single session).
3
- // PR99c v3.69.0 Multi-session tabs + browser-local command history.
4
- // v3.70.2 explicit composable imports (auto-import was missing the
5
- // newly added useTerminalThemes on dev servers that didn't restart).
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 { useTerminalTabs } from '~/composables/useTerminalTabs'
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
- const HISTORY_KEY = 'arka-terminal-command-history'
25
- const HISTORY_MAX = 500
14
+ onMounted(() => dock.open({ maximized: true }))
26
15
 
27
- interface HistoryEntry {
28
- ts: number
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="flex flex-col gap-3 h-full p-4">
272
- <p class="text-sm text-muted -mt-1">
273
- Real PTY shell run claude, codex, git, anything. ⌘T new · ⌘W close · ⌘1–8 switch · ⌃R search.
274
- </p>
275
-
276
- <div class="flex items-center gap-1 border-b border-default pb-2 overflow-x-auto">
277
- <div
278
- v-for="(tab, idx) in tabs"
279
- :key="tab.id"
280
- :class="[
281
- 'group flex items-center gap-1 px-3 py-1.5 rounded-t-md cursor-pointer text-sm shrink-0 border-b-2 transition-colors',
282
- activeId === tab.id
283
- ? 'bg-elevated/60 border-primary text-default'
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
- <UIcon name="i-lucide-terminal" class="size-6 mx-auto mb-2 opacity-50" />
393
- <p>No commands yet.</p>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.70.9",
3
+ "version": "3.71.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.70.9"
3
+ version = "3.71.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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