arkaos 3.67.0 → 3.69.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/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/audit.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/token.cpython-313.pyc +0 -0
- package/dashboard/app/components/Terminal.vue +174 -0
- package/dashboard/app/composables/useTerminalSession.ts +143 -0
- package/dashboard/app/composables/useTerminalTabs.ts +130 -0
- package/dashboard/app/pages/terminal.vue +215 -248
- package/dashboard/package.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.69.0
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR99b v3.68.0 — xterm.js terminal mount.
|
|
3
|
+
// PR99c v3.69.0 — accepts an external session via prop so the tab
|
|
4
|
+
// manager in /terminal can mount N instances, each bound to its own
|
|
5
|
+
// PTY session.
|
|
6
|
+
|
|
7
|
+
import { Terminal as XTerm } from '@xterm/xterm'
|
|
8
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
9
|
+
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
10
|
+
import { SearchAddon } from '@xterm/addon-search'
|
|
11
|
+
import '@xterm/xterm/css/xterm.css'
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
session?: ReturnType<typeof useTerminalSession>
|
|
15
|
+
onInputLine?: (line: string) => void
|
|
16
|
+
}
|
|
17
|
+
const props = defineProps<Props>()
|
|
18
|
+
|
|
19
|
+
const container = ref<HTMLDivElement | null>(null)
|
|
20
|
+
const session = props.session ?? useTerminalSession()
|
|
21
|
+
const term = shallowRef<XTerm | null>(null)
|
|
22
|
+
const fit = shallowRef<FitAddon | null>(null)
|
|
23
|
+
const search = shallowRef<SearchAddon | null>(null)
|
|
24
|
+
|
|
25
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
26
|
+
|
|
27
|
+
const themeArkaOSDark = {
|
|
28
|
+
background: '#0a0a0f',
|
|
29
|
+
foreground: '#e6e6f0',
|
|
30
|
+
cursor: '#7dd3fc',
|
|
31
|
+
cursorAccent: '#0a0a0f',
|
|
32
|
+
selectionBackground: '#1e3a5f',
|
|
33
|
+
black: '#0a0a0f',
|
|
34
|
+
red: '#f87171',
|
|
35
|
+
green: '#86efac',
|
|
36
|
+
yellow: '#fde68a',
|
|
37
|
+
blue: '#7dd3fc',
|
|
38
|
+
magenta: '#f0abfc',
|
|
39
|
+
cyan: '#67e8f9',
|
|
40
|
+
white: '#e6e6f0',
|
|
41
|
+
brightBlack: '#3f3f46',
|
|
42
|
+
brightRed: '#fca5a5',
|
|
43
|
+
brightGreen: '#bbf7d0',
|
|
44
|
+
brightYellow: '#fef3c7',
|
|
45
|
+
brightBlue: '#bae6fd',
|
|
46
|
+
brightMagenta: '#f5d0fe',
|
|
47
|
+
brightCyan: '#a5f3fc',
|
|
48
|
+
brightWhite: '#fafafa',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let unsubscribeOutput: (() => void) | null = null
|
|
52
|
+
let resizeObserver: ResizeObserver | null = null
|
|
53
|
+
|
|
54
|
+
onMounted(async () => {
|
|
55
|
+
if (!container.value) return
|
|
56
|
+
|
|
57
|
+
const t = new XTerm({
|
|
58
|
+
cursorBlink: true,
|
|
59
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
60
|
+
fontSize: 13,
|
|
61
|
+
lineHeight: 1.2,
|
|
62
|
+
scrollback: 5000,
|
|
63
|
+
theme: themeArkaOSDark,
|
|
64
|
+
allowProposedApi: true,
|
|
65
|
+
})
|
|
66
|
+
const fitAddon = new FitAddon()
|
|
67
|
+
const searchAddon = new SearchAddon()
|
|
68
|
+
t.loadAddon(fitAddon)
|
|
69
|
+
t.loadAddon(new WebLinksAddon())
|
|
70
|
+
t.loadAddon(searchAddon)
|
|
71
|
+
t.open(container.value)
|
|
72
|
+
fitAddon.fit()
|
|
73
|
+
|
|
74
|
+
term.value = t
|
|
75
|
+
fit.value = fitAddon
|
|
76
|
+
search.value = searchAddon
|
|
77
|
+
|
|
78
|
+
await session.open()
|
|
79
|
+
|
|
80
|
+
unsubscribeOutput = session.onOutput((chunk) => {
|
|
81
|
+
const text = decoder.decode(chunk, { stream: true })
|
|
82
|
+
t.write(text)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// PR99c v3.69.0 — line-buffer for command history without server-
|
|
86
|
+
// side audit. Captures only printable chars up to Enter; ignores
|
|
87
|
+
// arrow keys, ctrl combos, escape sequences.
|
|
88
|
+
let lineBuf = ''
|
|
89
|
+
t.onData((data) => {
|
|
90
|
+
for (const ch of data) {
|
|
91
|
+
if (ch === '\r' || ch === '\n') {
|
|
92
|
+
const cmd = lineBuf.trim()
|
|
93
|
+
if (cmd) props.onInputLine?.(cmd)
|
|
94
|
+
lineBuf = ''
|
|
95
|
+
} else if (ch === '\x7f' || ch === '\b') {
|
|
96
|
+
lineBuf = lineBuf.slice(0, -1)
|
|
97
|
+
} else if (ch >= ' ' && ch < '\x7f') {
|
|
98
|
+
lineBuf += ch
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
session.sendInput(data)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Initial size sync once the WS is open.
|
|
105
|
+
watch(session.status, (s) => {
|
|
106
|
+
if (s === 'open') {
|
|
107
|
+
const { cols, rows } = t
|
|
108
|
+
session.sendResize(cols, rows)
|
|
109
|
+
}
|
|
110
|
+
}, { immediate: true })
|
|
111
|
+
|
|
112
|
+
resizeObserver = new ResizeObserver(() => {
|
|
113
|
+
try {
|
|
114
|
+
fitAddon.fit()
|
|
115
|
+
session.sendResize(t.cols, t.rows)
|
|
116
|
+
} catch (_e) {
|
|
117
|
+
// dom may have unmounted
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
resizeObserver.observe(container.value)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
onBeforeUnmount(async () => {
|
|
124
|
+
unsubscribeOutput?.()
|
|
125
|
+
resizeObserver?.disconnect()
|
|
126
|
+
// PR99c: only close the session if we created it. When the parent
|
|
127
|
+
// owns the session (props.session), parent is responsible for close.
|
|
128
|
+
if (!props.session) {
|
|
129
|
+
await session.close()
|
|
130
|
+
}
|
|
131
|
+
term.value?.dispose()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
defineExpose({
|
|
135
|
+
status: session.status,
|
|
136
|
+
error: session.error,
|
|
137
|
+
meta: session.meta,
|
|
138
|
+
})
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<template>
|
|
142
|
+
<div class="relative h-full w-full bg-[#0a0a0f] rounded-lg overflow-hidden border border-default">
|
|
143
|
+
<div
|
|
144
|
+
v-if="session.status.value === 'connecting'"
|
|
145
|
+
class="absolute inset-0 z-10 grid place-items-center text-muted text-sm bg-[#0a0a0f]/80 backdrop-blur"
|
|
146
|
+
>
|
|
147
|
+
<div class="flex items-center gap-2">
|
|
148
|
+
<UIcon name="i-lucide-loader" class="animate-spin size-4" />
|
|
149
|
+
Spawning PTY…
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div
|
|
153
|
+
v-else-if="session.status.value === 'error' || session.status.value === 'closed'"
|
|
154
|
+
class="absolute top-2 right-2 z-10 text-xs rounded-md bg-elevated/90 px-2 py-1 border border-default"
|
|
155
|
+
>
|
|
156
|
+
<span v-if="session.status.value === 'error'" class="text-red-400">
|
|
157
|
+
{{ session.error.value || 'error' }}
|
|
158
|
+
</span>
|
|
159
|
+
<span v-else class="text-muted">closed</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div ref="container" class="absolute inset-0 p-2" />
|
|
162
|
+
</div>
|
|
163
|
+
</template>
|
|
164
|
+
|
|
165
|
+
<style scoped>
|
|
166
|
+
:deep(.xterm) {
|
|
167
|
+
height: 100%;
|
|
168
|
+
width: 100%;
|
|
169
|
+
padding: 4px;
|
|
170
|
+
}
|
|
171
|
+
:deep(.xterm-viewport) {
|
|
172
|
+
background-color: transparent !important;
|
|
173
|
+
}
|
|
174
|
+
</style>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// PR99b v3.68.0 — single PTY session lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// Encapsulates the REST + WebSocket handshake against /api/terminal/*.
|
|
4
|
+
// The composable owns no DOM and no xterm instance — it just produces
|
|
5
|
+
// the bytes-in/bytes-out duplex. The Terminal.vue component glues this
|
|
6
|
+
// to an xterm.js canvas.
|
|
7
|
+
|
|
8
|
+
export interface TerminalSessionMeta {
|
|
9
|
+
session_id: string
|
|
10
|
+
shell: string
|
|
11
|
+
cwd: string
|
|
12
|
+
token: string
|
|
13
|
+
ws_path: string
|
|
14
|
+
max_sessions: number
|
|
15
|
+
active_count: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TerminalSessionHandle {
|
|
19
|
+
meta: Ref<TerminalSessionMeta | null>
|
|
20
|
+
status: Ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>
|
|
21
|
+
error: Ref<string | null>
|
|
22
|
+
open: () => Promise<void>
|
|
23
|
+
sendInput: (data: string) => void
|
|
24
|
+
sendResize: (cols: number, rows: number) => void
|
|
25
|
+
close: () => Promise<void>
|
|
26
|
+
onOutput: (cb: (chunk: Uint8Array) => void) => () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useTerminalSession(
|
|
30
|
+
apiBaseOverride?: string,
|
|
31
|
+
): TerminalSessionHandle {
|
|
32
|
+
// PR99c v3.69.0 — apiBaseOverride lets useTerminalTabs construct
|
|
33
|
+
// sessions from user-event handlers without re-entering Nuxt's
|
|
34
|
+
// composable context.
|
|
35
|
+
const apiBase = apiBaseOverride ?? useApi().apiBase
|
|
36
|
+
const meta = ref<TerminalSessionMeta | null>(null)
|
|
37
|
+
const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
|
|
38
|
+
const error = ref<string | null>(null)
|
|
39
|
+
|
|
40
|
+
let ws: WebSocket | null = null
|
|
41
|
+
const listeners: Array<(chunk: Uint8Array) => void> = []
|
|
42
|
+
|
|
43
|
+
function wsUrl(path: string, token: string): string {
|
|
44
|
+
const base = apiBase.replace(/^http/, 'ws')
|
|
45
|
+
return `${base}${path}?token=${encodeURIComponent(token)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function createSession(): Promise<TerminalSessionMeta> {
|
|
49
|
+
const r = await fetch(`${apiBase}/api/terminal/sessions`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ cols: 120, rows: 32 }),
|
|
53
|
+
})
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
const body = await r.text()
|
|
56
|
+
throw new Error(`create session failed: ${r.status} ${body}`)
|
|
57
|
+
}
|
|
58
|
+
return await r.json()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function open() {
|
|
62
|
+
if (status.value === 'open' || status.value === 'connecting') return
|
|
63
|
+
status.value = 'connecting'
|
|
64
|
+
error.value = null
|
|
65
|
+
try {
|
|
66
|
+
const m = await createSession()
|
|
67
|
+
meta.value = m
|
|
68
|
+
ws = new WebSocket(wsUrl(m.ws_path, m.token))
|
|
69
|
+
ws.binaryType = 'arraybuffer'
|
|
70
|
+
ws.onopen = () => {
|
|
71
|
+
status.value = 'open'
|
|
72
|
+
}
|
|
73
|
+
ws.onmessage = (ev) => {
|
|
74
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
75
|
+
const chunk = new Uint8Array(ev.data)
|
|
76
|
+
for (const cb of listeners) cb(chunk)
|
|
77
|
+
} else if (typeof ev.data === 'string') {
|
|
78
|
+
const enc = new TextEncoder().encode(ev.data)
|
|
79
|
+
for (const cb of listeners) cb(enc)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ws.onerror = () => {
|
|
83
|
+
status.value = 'error'
|
|
84
|
+
error.value = 'websocket error'
|
|
85
|
+
}
|
|
86
|
+
ws.onclose = (ev) => {
|
|
87
|
+
status.value = 'closed'
|
|
88
|
+
if (ev.code !== 1000 && ev.code !== 1005) {
|
|
89
|
+
error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
status.value = 'error'
|
|
94
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendInput(data: string) {
|
|
99
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
100
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sendResize(cols: number, rows: number) {
|
|
104
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
105
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function close() {
|
|
109
|
+
try {
|
|
110
|
+
ws?.close(1000, 'client close')
|
|
111
|
+
} catch (_e) {
|
|
112
|
+
// ignore
|
|
113
|
+
}
|
|
114
|
+
const id = meta.value?.session_id
|
|
115
|
+
if (id) {
|
|
116
|
+
try {
|
|
117
|
+
await fetch(`${apiBase}/api/terminal/sessions/${id}`, { method: 'DELETE' })
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
// ignore — best-effort cleanup; backend reaper will catch it
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
status.value = 'closed'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function onOutput(cb: (chunk: Uint8Array) => void) {
|
|
126
|
+
listeners.push(cb)
|
|
127
|
+
return () => {
|
|
128
|
+
const idx = listeners.indexOf(cb)
|
|
129
|
+
if (idx >= 0) listeners.splice(idx, 1)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
meta,
|
|
135
|
+
status,
|
|
136
|
+
error,
|
|
137
|
+
open,
|
|
138
|
+
sendInput,
|
|
139
|
+
sendResize,
|
|
140
|
+
close,
|
|
141
|
+
onOutput,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// PR99c v3.69.0 — multi-session tab manager for /terminal.
|
|
2
|
+
//
|
|
3
|
+
// Owns an array of `useTerminalSession()` handles plus the active tab
|
|
4
|
+
// id. Persists tab titles (not PTYs — those are re-created fresh on
|
|
5
|
+
// reload) to localStorage. Enforces an 8-tab cap (matching the backend
|
|
6
|
+
// default).
|
|
7
|
+
|
|
8
|
+
export interface TerminalTab {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
session: ReturnType<typeof useTerminalSession>
|
|
12
|
+
createdAt: number
|
|
13
|
+
hasActivity: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = 'arka-terminal-tab-titles'
|
|
17
|
+
const MAX_TABS = 8
|
|
18
|
+
|
|
19
|
+
interface PersistedTitle {
|
|
20
|
+
id: string
|
|
21
|
+
title: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadPersistedTitles(): Record<string, string> {
|
|
25
|
+
if (typeof localStorage === 'undefined') return {}
|
|
26
|
+
try {
|
|
27
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
28
|
+
if (!raw) return {}
|
|
29
|
+
const parsed = JSON.parse(raw) as PersistedTitle[]
|
|
30
|
+
return Object.fromEntries(parsed.map(p => [p.id, p.title]))
|
|
31
|
+
} catch {
|
|
32
|
+
return {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persistTitles(tabs: TerminalTab[]) {
|
|
37
|
+
if (typeof localStorage === 'undefined') return
|
|
38
|
+
try {
|
|
39
|
+
const payload: PersistedTitle[] = tabs.map(t => ({ id: t.id, title: t.title }))
|
|
40
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
|
41
|
+
} catch {
|
|
42
|
+
// quota / private mode — swallow
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useTerminalTabs() {
|
|
47
|
+
const { apiBase } = useApi()
|
|
48
|
+
const tabs = ref<TerminalTab[]>([])
|
|
49
|
+
const activeId = ref<string | null>(null)
|
|
50
|
+
const capReached = computed(() => tabs.value.length >= MAX_TABS)
|
|
51
|
+
|
|
52
|
+
function makeId(): string {
|
|
53
|
+
return Math.random().toString(36).slice(2, 10)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function newTab(): TerminalTab | null {
|
|
57
|
+
if (tabs.value.length >= MAX_TABS) return null
|
|
58
|
+
const id = makeId()
|
|
59
|
+
const persistedTitles = loadPersistedTitles()
|
|
60
|
+
const titleFromStorage = persistedTitles[id]
|
|
61
|
+
const tab: TerminalTab = {
|
|
62
|
+
id,
|
|
63
|
+
title: titleFromStorage || `Session ${tabs.value.length + 1}`,
|
|
64
|
+
session: useTerminalSession(apiBase),
|
|
65
|
+
createdAt: Date.now(),
|
|
66
|
+
hasActivity: false,
|
|
67
|
+
}
|
|
68
|
+
tabs.value.push(tab)
|
|
69
|
+
activeId.value = id
|
|
70
|
+
persistTitles(tabs.value)
|
|
71
|
+
return tab
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function closeTab(id: string) {
|
|
75
|
+
const idx = tabs.value.findIndex(t => t.id === id)
|
|
76
|
+
if (idx < 0) return
|
|
77
|
+
const tab = tabs.value[idx]
|
|
78
|
+
if (!tab) return
|
|
79
|
+
await tab.session.close()
|
|
80
|
+
tabs.value.splice(idx, 1)
|
|
81
|
+
if (activeId.value === id) {
|
|
82
|
+
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
|
|
83
|
+
activeId.value = next ? next.id : null
|
|
84
|
+
}
|
|
85
|
+
persistTitles(tabs.value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function switchTab(id: string) {
|
|
89
|
+
const t = tabs.value.find(t => t.id === id)
|
|
90
|
+
if (!t) return
|
|
91
|
+
activeId.value = id
|
|
92
|
+
t.hasActivity = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renameTab(id: string, title: string) {
|
|
96
|
+
const t = tabs.value.find(t => t.id === id)
|
|
97
|
+
if (!t) return
|
|
98
|
+
t.title = title.trim() || t.title
|
|
99
|
+
persistTitles(tabs.value)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function markActivity(id: string) {
|
|
103
|
+
if (activeId.value === id) return
|
|
104
|
+
const t = tabs.value.find(t => t.id === id)
|
|
105
|
+
if (t) t.hasActivity = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function closeAll() {
|
|
109
|
+
await Promise.all(tabs.value.map(t => t.session.close()))
|
|
110
|
+
tabs.value = []
|
|
111
|
+
activeId.value = null
|
|
112
|
+
persistTitles([])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const activeTab = computed(() => tabs.value.find(t => t.id === activeId.value) ?? null)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
tabs,
|
|
119
|
+
activeId,
|
|
120
|
+
activeTab,
|
|
121
|
+
capReached,
|
|
122
|
+
maxTabs: MAX_TABS,
|
|
123
|
+
newTab,
|
|
124
|
+
closeTab,
|
|
125
|
+
switchTab,
|
|
126
|
+
renameTab,
|
|
127
|
+
markActivity,
|
|
128
|
+
closeAll,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,278 +1,245 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
2
|
+
// PR99b v3.68.0 — Real-shell terminal (single session).
|
|
3
|
+
// PR99c v3.69.0 — Multi-session tabs + browser-local command history.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// Each tab owns its own PTY session. PTYs are NOT persisted across
|
|
6
|
+
// reloads (they're per-process); only tab titles are. The 8-tab cap
|
|
7
|
+
// matches the backend default.
|
|
8
|
+
|
|
9
|
+
definePageMeta({ layout: 'default' })
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
tabs,
|
|
13
|
+
activeId,
|
|
14
|
+
activeTab,
|
|
15
|
+
capReached,
|
|
16
|
+
maxTabs,
|
|
17
|
+
newTab,
|
|
18
|
+
closeTab,
|
|
19
|
+
switchTab,
|
|
20
|
+
renameTab,
|
|
21
|
+
} = useTerminalTabs()
|
|
22
|
+
|
|
23
|
+
const HISTORY_KEY = 'arka-terminal-command-history'
|
|
24
|
+
const HISTORY_MAX = 500
|
|
11
25
|
|
|
12
|
-
interface
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
choices: string[]
|
|
16
|
-
default: string
|
|
26
|
+
interface HistoryEntry {
|
|
27
|
+
ts: number
|
|
28
|
+
cmd: string
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
function loadHistory(): HistoryEntry[] {
|
|
32
|
+
if (typeof localStorage === 'undefined') return []
|
|
33
|
+
try {
|
|
34
|
+
const raw = localStorage.getItem(HISTORY_KEY)
|
|
35
|
+
return raw ? (JSON.parse(raw) as HistoryEntry[]) : []
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
|
|
27
|
-
stdout: string
|
|
28
|
-
stderr: string
|
|
29
|
-
exit_code: number
|
|
30
|
-
duration_ms: number
|
|
31
|
-
command: string
|
|
32
|
-
}
|
|
41
|
+
const history = ref<HistoryEntry[]>(loadHistory())
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
function recordCommand(cmd: string) {
|
|
44
|
+
const trimmed = cmd.trim()
|
|
45
|
+
if (!trimmed || trimmed.length < 2) return
|
|
46
|
+
history.value.unshift({ ts: Date.now(), cmd: trimmed })
|
|
47
|
+
if (history.value.length > HISTORY_MAX) {
|
|
48
|
+
history.value = history.value.slice(0, HISTORY_MAX)
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
|
|
52
|
+
} catch {
|
|
53
|
+
// quota — silently truncate further
|
|
54
|
+
history.value = history.value.slice(0, 200)
|
|
55
|
+
}
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const { data: cmdData, status } = await fetchApi<{ commands: CommandEntry[] }>(
|
|
45
|
-
'/api/terminal/commands',
|
|
46
|
-
)
|
|
47
|
-
const commands = computed<CommandEntry[]>(() => cmdData.value?.commands ?? [])
|
|
58
|
+
const editingTabId = ref<string | null>(null)
|
|
59
|
+
const renameDraft = ref('')
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// the operator's current selection for each arg.
|
|
54
|
-
const argState = reactive<Record<string, Record<string, string>>>({})
|
|
61
|
+
function startRename(tabId: string, currentTitle: string) {
|
|
62
|
+
editingTabId.value = tabId
|
|
63
|
+
renameDraft.value = currentTitle
|
|
64
|
+
}
|
|
55
65
|
|
|
56
|
-
function
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
for (const arg of cmd.args) {
|
|
60
|
-
if (argState[cmd.id][arg.name] === undefined) {
|
|
61
|
-
argState[cmd.id][arg.name] = arg.default
|
|
62
|
-
}
|
|
66
|
+
function commitRename() {
|
|
67
|
+
if (editingTabId.value) {
|
|
68
|
+
renameTab(editingTabId.value, renameDraft.value)
|
|
63
69
|
}
|
|
70
|
+
editingTabId.value = null
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const res = await $fetch<ExecResult & { error?: string }>(
|
|
71
|
-
`${apiBase}/api/terminal/exec`,
|
|
72
|
-
{
|
|
73
|
-
method: 'POST',
|
|
74
|
-
body: {
|
|
75
|
-
command_id: cmd.id,
|
|
76
|
-
args: cmd.args ? argState[cmd.id] : undefined,
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
)
|
|
80
|
-
if (res.error) throw new Error(res.error)
|
|
81
|
-
history.value = [
|
|
82
|
-
{
|
|
83
|
-
id: cmd.id,
|
|
84
|
-
label: cmd.label,
|
|
85
|
-
result: res,
|
|
86
|
-
ts: new Date().toISOString(),
|
|
87
|
-
},
|
|
88
|
-
...history.value,
|
|
89
|
-
].slice(0, 20)
|
|
90
|
-
if (res.exit_code === 0) {
|
|
91
|
-
toast.add({
|
|
92
|
-
title: `${cmd.label} · ok`,
|
|
93
|
-
description: `${res.duration_ms}ms`,
|
|
94
|
-
color: 'success',
|
|
95
|
-
icon: 'i-lucide-check',
|
|
96
|
-
})
|
|
97
|
-
} else {
|
|
98
|
-
toast.add({
|
|
99
|
-
title: `${cmd.label} · exit ${res.exit_code}`,
|
|
100
|
-
description: `${res.duration_ms}ms · ${res.stderr.slice(0, 80) || 'no stderr'}`,
|
|
101
|
-
color: 'warning',
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
} catch (err) {
|
|
73
|
+
const toast = useToast()
|
|
74
|
+
|
|
75
|
+
function tryNewTab() {
|
|
76
|
+
if (capReached.value) {
|
|
105
77
|
toast.add({
|
|
106
|
-
title: '
|
|
107
|
-
description:
|
|
108
|
-
color: '
|
|
78
|
+
title: 'Maximum sessions reached',
|
|
79
|
+
description: `You can have up to ${maxTabs} sessions open at once. Close one to open a new one.`,
|
|
80
|
+
color: 'warning',
|
|
81
|
+
icon: 'i-lucide-alert-triangle',
|
|
109
82
|
})
|
|
110
|
-
|
|
111
|
-
running.value = null
|
|
83
|
+
return
|
|
112
84
|
}
|
|
85
|
+
newTab()
|
|
113
86
|
}
|
|
114
87
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
88
|
+
// Keyboard shortcuts.
|
|
89
|
+
defineShortcuts({
|
|
90
|
+
meta_t: { handler: tryNewTab, usingInput: false },
|
|
91
|
+
meta_w: {
|
|
92
|
+
handler: () => {
|
|
93
|
+
if (activeId.value) closeTab(activeId.value)
|
|
94
|
+
},
|
|
95
|
+
usingInput: false,
|
|
96
|
+
},
|
|
97
|
+
meta_1: { handler: () => switchByIndex(0), usingInput: false },
|
|
98
|
+
meta_2: { handler: () => switchByIndex(1), usingInput: false },
|
|
99
|
+
meta_3: { handler: () => switchByIndex(2), usingInput: false },
|
|
100
|
+
meta_4: { handler: () => switchByIndex(3), usingInput: false },
|
|
101
|
+
meta_5: { handler: () => switchByIndex(4), usingInput: false },
|
|
102
|
+
meta_6: { handler: () => switchByIndex(5), usingInput: false },
|
|
103
|
+
meta_7: { handler: () => switchByIndex(6), usingInput: false },
|
|
104
|
+
meta_8: { handler: () => switchByIndex(7), usingInput: false },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function switchByIndex(idx: number) {
|
|
108
|
+
const t = tabs.value[idx]
|
|
109
|
+
if (t) switchTab(t.id)
|
|
120
110
|
}
|
|
121
111
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
112
|
+
// First-visit: open one tab automatically.
|
|
113
|
+
onMounted(() => {
|
|
114
|
+
if (tabs.value.length === 0) newTab()
|
|
115
|
+
})
|
|
125
116
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const m = Math.floor(s / 60)
|
|
133
|
-
if (m < 60) return `${m}m ago`
|
|
134
|
-
return `${Math.floor(m / 60)}h ago`
|
|
135
|
-
}
|
|
117
|
+
onBeforeUnmount(async () => {
|
|
118
|
+
// Don't proactively close tabs — operator may navigate back. Backend
|
|
119
|
+
// reaper will GC after the idle timeout (30 min).
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const showHistory = ref(false)
|
|
136
123
|
</script>
|
|
137
124
|
|
|
138
125
|
<template>
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
126
|
+
<div class="flex flex-col gap-3 h-full">
|
|
127
|
+
<header class="flex items-center justify-between gap-3 flex-wrap">
|
|
128
|
+
<div>
|
|
129
|
+
<h1 class="text-2xl font-semibold">Terminal</h1>
|
|
130
|
+
<p class="text-sm text-muted">
|
|
131
|
+
Real PTY shell — run claude, codex, git, anything. ⌘T new ·
|
|
132
|
+
⌘W close · ⌘1–8 switch.
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="flex items-center gap-2">
|
|
136
|
+
<UBadge color="warning" variant="soft" size="sm">
|
|
137
|
+
<UIcon name="i-lucide-shield" class="size-3 mr-1" />
|
|
138
|
+
localhost only
|
|
139
|
+
</UBadge>
|
|
140
|
+
<UButton
|
|
141
|
+
size="xs"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
:icon="showHistory ? 'i-lucide-x' : 'i-lucide-history'"
|
|
144
|
+
@click="showHistory = !showHistory"
|
|
145
|
+
>
|
|
146
|
+
History ({{ history.length }})
|
|
147
|
+
</UButton>
|
|
148
|
+
</div>
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
<div class="flex items-center gap-1 border-b border-default pb-2 overflow-x-auto">
|
|
152
|
+
<div
|
|
153
|
+
v-for="(tab, idx) in tabs"
|
|
154
|
+
:key="tab.id"
|
|
155
|
+
:class="[
|
|
156
|
+
'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',
|
|
157
|
+
activeId === tab.id
|
|
158
|
+
? 'bg-elevated/60 border-primary text-default'
|
|
159
|
+
: 'border-transparent text-muted hover:text-default hover:bg-elevated/30',
|
|
160
|
+
]"
|
|
161
|
+
@click="switchTab(tab.id)"
|
|
162
|
+
@dblclick="startRename(tab.id, tab.title)"
|
|
163
|
+
>
|
|
164
|
+
<span class="text-xs text-muted">{{ idx + 1 }}</span>
|
|
165
|
+
<UInput
|
|
166
|
+
v-if="editingTabId === tab.id"
|
|
167
|
+
v-model="renameDraft"
|
|
168
|
+
size="xs"
|
|
169
|
+
autofocus
|
|
170
|
+
@keydown.enter="commitRename"
|
|
171
|
+
@keydown.esc="editingTabId = null"
|
|
172
|
+
@blur="commitRename"
|
|
173
|
+
/>
|
|
174
|
+
<span v-else>{{ tab.title }}</span>
|
|
175
|
+
<UIcon
|
|
176
|
+
v-if="tab.hasActivity && activeId !== tab.id"
|
|
177
|
+
name="i-lucide-circle"
|
|
178
|
+
class="size-2 text-amber-400 fill-current"
|
|
179
|
+
/>
|
|
180
|
+
<button
|
|
181
|
+
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"
|
|
182
|
+
@click.stop="closeTab(tab.id)"
|
|
183
|
+
>
|
|
184
|
+
<UIcon name="i-lucide-x" class="size-3" />
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
<UButton
|
|
188
|
+
size="xs"
|
|
189
|
+
variant="ghost"
|
|
190
|
+
icon="i-lucide-plus"
|
|
191
|
+
:disabled="capReached"
|
|
192
|
+
:title="capReached ? `Max ${maxTabs} sessions` : 'New session'"
|
|
193
|
+
@click="tryNewTab"
|
|
194
|
+
>
|
|
195
|
+
New
|
|
196
|
+
</UButton>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div class="flex-1 min-h-[480px] flex gap-3">
|
|
200
|
+
<div class="flex-1 relative">
|
|
201
|
+
<template v-for="tab in tabs" :key="tab.id">
|
|
202
|
+
<Terminal
|
|
203
|
+
v-show="activeId === tab.id"
|
|
204
|
+
:session="tab.session"
|
|
205
|
+
:on-input-line="recordCommand"
|
|
206
|
+
class="absolute inset-0"
|
|
207
|
+
/>
|
|
147
208
|
</template>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
209
|
+
<div
|
|
210
|
+
v-if="tabs.length === 0"
|
|
211
|
+
class="absolute inset-0 grid place-items-center text-muted text-sm"
|
|
212
|
+
>
|
|
213
|
+
No active sessions. Press ⌘T or click "+ New" to open one.
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<aside
|
|
217
|
+
v-if="showHistory"
|
|
218
|
+
class="w-72 shrink-0 rounded-lg border border-default bg-elevated/10 overflow-hidden flex flex-col"
|
|
158
219
|
>
|
|
159
|
-
<div class="
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class="rounded-lg border border-default p-3 space-y-2"
|
|
176
|
-
>
|
|
177
|
-
<div>
|
|
178
|
-
<p class="text-sm font-semibold">{{ cmd.label }}</p>
|
|
179
|
-
<p class="text-xs text-muted">{{ cmd.description }}</p>
|
|
180
|
-
</div>
|
|
181
|
-
<!-- PR96b v3.56.0 — arg pickers for parameterised commands -->
|
|
182
|
-
<div v-if="cmd.args && cmd.args.length > 0" class="grid grid-cols-2 gap-2">
|
|
183
|
-
<UFormField
|
|
184
|
-
v-for="arg in cmd.args"
|
|
185
|
-
:key="arg.name"
|
|
186
|
-
:label="arg.label"
|
|
187
|
-
size="xs"
|
|
188
|
-
>
|
|
189
|
-
<USelect
|
|
190
|
-
:model-value="(argState[cmd.id] || {})[arg.name] || arg.default"
|
|
191
|
-
:items="arg.choices.map((c) => ({ label: c, value: c }))"
|
|
192
|
-
size="xs"
|
|
193
|
-
class="w-full"
|
|
194
|
-
@update:model-value="(v) => {
|
|
195
|
-
ensureArgState(cmd)
|
|
196
|
-
argState[cmd.id][arg.name] = String(v)
|
|
197
|
-
}"
|
|
198
|
-
/>
|
|
199
|
-
</UFormField>
|
|
200
|
-
</div>
|
|
201
|
-
<UButton
|
|
202
|
-
label="Run"
|
|
203
|
-
icon="i-lucide-play"
|
|
204
|
-
color="primary"
|
|
205
|
-
size="sm"
|
|
206
|
-
block
|
|
207
|
-
:loading="running === cmd.id"
|
|
208
|
-
:disabled="running !== null && running !== cmd.id"
|
|
209
|
-
@click="run(cmd)"
|
|
210
|
-
/>
|
|
211
|
-
</div>
|
|
212
|
-
</div>
|
|
213
|
-
</UCard>
|
|
214
|
-
|
|
215
|
-
<UCard v-if="history.length > 0">
|
|
216
|
-
<template #header>
|
|
217
|
-
<div class="flex items-center justify-between gap-3">
|
|
218
|
-
<div>
|
|
219
|
-
<h3 class="text-lg font-bold">Recent runs</h3>
|
|
220
|
-
<p class="text-xs text-muted mt-0.5">
|
|
221
|
-
Last {{ history.length }} commands · most recent first
|
|
222
|
-
</p>
|
|
223
|
-
</div>
|
|
224
|
-
<UButton label="Clear" variant="ghost" size="xs" @click="clearHistory" />
|
|
225
|
-
</div>
|
|
226
|
-
</template>
|
|
227
|
-
<ul class="space-y-4">
|
|
228
|
-
<li
|
|
229
|
-
v-for="entry in history"
|
|
230
|
-
:key="`${entry.ts}-${entry.id}`"
|
|
231
|
-
class="rounded-lg border border-default overflow-hidden"
|
|
232
|
-
>
|
|
233
|
-
<div class="px-3 py-2 bg-elevated/30 flex items-center justify-between gap-3 text-xs">
|
|
234
|
-
<div class="min-w-0 flex items-center gap-2">
|
|
235
|
-
<UBadge
|
|
236
|
-
:label="entry.result.exit_code === 0 ? 'ok' : `exit ${entry.result.exit_code}`"
|
|
237
|
-
:color="entry.result.exit_code === 0 ? 'success' : 'warning'"
|
|
238
|
-
variant="subtle"
|
|
239
|
-
size="xs"
|
|
240
|
-
/>
|
|
241
|
-
<span class="font-mono truncate">{{ entry.result.command }}</span>
|
|
242
|
-
</div>
|
|
243
|
-
<div class="flex items-center gap-2 shrink-0">
|
|
244
|
-
<span class="text-muted font-mono">{{ entry.result.duration_ms }}ms</span>
|
|
245
|
-
<span class="text-muted">{{ relative(entry.ts) }}</span>
|
|
246
|
-
<UButton
|
|
247
|
-
icon="i-lucide-clipboard-copy"
|
|
248
|
-
variant="ghost"
|
|
249
|
-
size="xs"
|
|
250
|
-
aria-label="Copy output"
|
|
251
|
-
@click="copyOutput(entry)"
|
|
252
|
-
/>
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
<pre
|
|
256
|
-
v-if="entry.result.stdout"
|
|
257
|
-
class="p-3 text-xs font-mono whitespace-pre overflow-x-auto"
|
|
258
|
-
>{{ entry.result.stdout }}</pre>
|
|
259
|
-
<pre
|
|
260
|
-
v-if="entry.result.stderr"
|
|
261
|
-
class="p-3 text-xs font-mono whitespace-pre overflow-x-auto text-rose-500 border-t border-default"
|
|
262
|
-
>{{ entry.result.stderr }}</pre>
|
|
263
|
-
</li>
|
|
264
|
-
</ul>
|
|
265
|
-
</UCard>
|
|
266
|
-
|
|
267
|
-
<p class="text-xs text-muted">
|
|
268
|
-
Want a different command? Add it to
|
|
269
|
-
<code class="font-mono">TERMINAL_ALLOWLIST</code> in
|
|
270
|
-
<code class="font-mono">scripts/dashboard-api.py</code> and
|
|
271
|
-
restart the backend. Arbitrary shell execution from the
|
|
272
|
-
dashboard is intentionally not supported.
|
|
273
|
-
</p>
|
|
220
|
+
<div class="px-3 py-2 border-b border-default text-xs uppercase tracking-wide text-muted">
|
|
221
|
+
Command history
|
|
222
|
+
</div>
|
|
223
|
+
<div class="flex-1 overflow-auto text-xs font-mono">
|
|
224
|
+
<button
|
|
225
|
+
v-for="(entry, i) in history"
|
|
226
|
+
:key="i"
|
|
227
|
+
class="w-full text-left px-3 py-1.5 hover:bg-default/40 truncate"
|
|
228
|
+
:title="entry.cmd"
|
|
229
|
+
@click="activeTab?.session.sendInput(entry.cmd)"
|
|
230
|
+
>
|
|
231
|
+
{{ entry.cmd }}
|
|
232
|
+
</button>
|
|
233
|
+
<div v-if="history.length === 0" class="px-3 py-4 text-muted text-center">
|
|
234
|
+
No commands yet
|
|
235
|
+
</div>
|
|
274
236
|
</div>
|
|
275
|
-
</
|
|
276
|
-
</
|
|
277
|
-
|
|
237
|
+
</aside>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<footer class="text-xs text-muted">
|
|
241
|
+
Sessions live on the backend until you close them or 30 min idle.
|
|
242
|
+
History stays in this browser only.
|
|
243
|
+
</footer>
|
|
244
|
+
</div>
|
|
278
245
|
</template>
|
package/dashboard/package.json
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"@unovis/vue": "^1.6.4",
|
|
21
21
|
"@vueuse/core": "^14.2.1",
|
|
22
22
|
"@vueuse/nuxt": "^14.2.1",
|
|
23
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
24
|
+
"@xterm/addon-search": "^0.16.0",
|
|
25
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
26
|
+
"@xterm/xterm": "^6.0.0",
|
|
23
27
|
"date-fns": "^4.1.0",
|
|
24
28
|
"marked": "^15.0.0",
|
|
25
29
|
"nuxt": "^4.4.2",
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|