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 CHANGED
@@ -1 +1 @@
1
- 3.67.0
1
+ 3.69.0
@@ -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
- // PR95a v3.51.0 — Dashboard terminal (allowlist mode).
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
- // Operator picks one of the allowlisted commands; backend runs it via
5
- // subprocess.run (no shell). Output streams into the history block.
6
- //
7
- // Note: vue-termui is for building Vue TUI apps that RUN in a terminal,
8
- // not for embedding an arbitrary shell in a browser. The dashboard
9
- // instead ships a controlled command runner with allowlist + capped
10
- // output. xterm.js-style PTY can be a later upgrade if needed.
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 CommandArg {
13
- name: string
14
- label: string
15
- choices: string[]
16
- default: string
26
+ interface HistoryEntry {
27
+ ts: number
28
+ cmd: string
17
29
  }
18
30
 
19
- interface CommandEntry {
20
- id: string
21
- label: string
22
- description: string
23
- args?: CommandArg[]
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
- interface ExecResult {
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
- interface HistoryEntry {
35
- id: string
36
- label: string
37
- result: ExecResult
38
- ts: string
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 { fetchApi, apiBase } = useApi()
42
- const toast = useToast()
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
- const running = ref<string | null>(null)
50
- const history = ref<HistoryEntry[]>([])
51
-
52
- // PR96b v3.56.0 — per-command arg state. Keyed by command id, holds
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 ensureArgState(cmd: CommandEntry) {
57
- if (!cmd.args || cmd.args.length === 0) return
58
- if (!argState[cmd.id]) argState[cmd.id] = {}
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
- async function run(cmd: CommandEntry) {
67
- running.value = cmd.id
68
- ensureArgState(cmd)
69
- try {
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: 'Run failed',
107
- description: err instanceof Error ? err.message : 'unknown error',
108
- color: 'error',
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
- } finally {
111
- running.value = null
83
+ return
112
84
  }
85
+ newTab()
113
86
  }
114
87
 
115
- function copyOutput(entry: HistoryEntry) {
116
- if (typeof navigator === 'undefined' || !navigator.clipboard) return
117
- const body = entry.result.stdout || entry.result.stderr
118
- void navigator.clipboard.writeText(body)
119
- toast.add({ title: 'Copied to clipboard', color: 'success', icon: 'i-lucide-clipboard-check' })
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
- function clearHistory() {
123
- history.value = []
124
- }
112
+ // First-visit: open one tab automatically.
113
+ onMounted(() => {
114
+ if (tabs.value.length === 0) newTab()
115
+ })
125
116
 
126
- function relative(iso: string): string {
127
- const ts = Date.parse(iso)
128
- if (Number.isNaN(ts)) return iso
129
- const diff = Date.now() - ts
130
- const s = Math.floor(diff / 1000)
131
- if (s < 60) return `${s}s ago`
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
- <UDashboardPanel id="terminal">
140
- <template #header>
141
- <UDashboardNavbar title="Terminal">
142
- <template #leading>
143
- <UDashboardSidebarCollapse />
144
- </template>
145
- <template #trailing>
146
- <UBadge label="Allowlist mode" variant="subtle" color="primary" size="sm" />
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
- </UDashboardNavbar>
149
- </template>
150
-
151
- <template #body>
152
- <DashboardState
153
- :status="status"
154
- :empty="commands.length === 0"
155
- empty-title="No allowlisted commands"
156
- empty-description="The backend exposes no terminal commands. Add to TERMINAL_ALLOWLIST."
157
- empty-icon="i-lucide-terminal"
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="space-y-5 max-w-4xl">
160
- <UCard>
161
- <template #header>
162
- <div>
163
- <h3 class="text-lg font-bold">Commands</h3>
164
- <p class="text-xs text-muted mt-0.5">
165
- Server-enforced allowlist. Each command runs via
166
- <code class="font-mono">subprocess.run</code> with explicit argv
167
- no shell, no globbing, no pipes. Cap: 15s timeout, 20K chars per stream.
168
- </p>
169
- </div>
170
- </template>
171
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
172
- <div
173
- v-for="cmd in commands"
174
- :key="cmd.id"
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
- </DashboardState>
276
- </template>
277
- </UDashboardPanel>
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>
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.67.0",
3
+ "version": "3.69.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.67.0"
3
+ version = "3.69.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"}