arkaos 3.68.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.68.0
1
+ 3.69.0
@@ -1,9 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  // PR99b v3.68.0 — xterm.js terminal mount.
3
- //
4
- // Glues @xterm/xterm to a single PTY session from useTerminalSession.
5
- // One terminal per component instance; the multi-tab page in PR99c
6
- // will spawn N of these.
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.
7
6
 
8
7
  import { Terminal as XTerm } from '@xterm/xterm'
9
8
  import { FitAddon } from '@xterm/addon-fit'
@@ -11,8 +10,14 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
11
10
  import { SearchAddon } from '@xterm/addon-search'
12
11
  import '@xterm/xterm/css/xterm.css'
13
12
 
13
+ interface Props {
14
+ session?: ReturnType<typeof useTerminalSession>
15
+ onInputLine?: (line: string) => void
16
+ }
17
+ const props = defineProps<Props>()
18
+
14
19
  const container = ref<HTMLDivElement | null>(null)
15
- const session = useTerminalSession()
20
+ const session = props.session ?? useTerminalSession()
16
21
  const term = shallowRef<XTerm | null>(null)
17
22
  const fit = shallowRef<FitAddon | null>(null)
18
23
  const search = shallowRef<SearchAddon | null>(null)
@@ -77,7 +82,22 @@ onMounted(async () => {
77
82
  t.write(text)
78
83
  })
79
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 = ''
80
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
+ }
81
101
  session.sendInput(data)
82
102
  })
83
103
 
@@ -103,7 +123,11 @@ onMounted(async () => {
103
123
  onBeforeUnmount(async () => {
104
124
  unsubscribeOutput?.()
105
125
  resizeObserver?.disconnect()
106
- await session.close()
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
+ }
107
131
  term.value?.dispose()
108
132
  })
109
133
 
@@ -26,8 +26,13 @@ export interface TerminalSessionHandle {
26
26
  onOutput: (cb: (chunk: Uint8Array) => void) => () => void
27
27
  }
28
28
 
29
- export function useTerminalSession(): TerminalSessionHandle {
30
- const { apiBase } = useApi()
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
31
36
  const meta = ref<TerminalSessionMeta | null>(null)
32
37
  const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
33
38
  const error = ref<string | null>(null)
@@ -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,25 +1,135 @@
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
- // Replaces the v3.51.0 allowlist UI. Each visit spawns a fresh PTY
5
- // session on the backend, wired to xterm.js. Multi-session tabs and
6
- // history land in PR99c. The old allowlist endpoints stay in parallel
7
- // until PR99d, but the UI is gone as of this release.
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
8
 
9
9
  definePageMeta({ layout: 'default' })
10
10
 
11
- const terminalRef = ref<InstanceType<typeof import('~/components/Terminal.vue').default> | null>(null)
12
- const expanded = ref(false)
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
25
+
26
+ interface HistoryEntry {
27
+ ts: number
28
+ cmd: string
29
+ }
30
+
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
+ }
39
+ }
40
+
41
+ const history = ref<HistoryEntry[]>(loadHistory())
42
+
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
+ }
56
+ }
57
+
58
+ const editingTabId = ref<string | null>(null)
59
+ const renameDraft = ref('')
60
+
61
+ function startRename(tabId: string, currentTitle: string) {
62
+ editingTabId.value = tabId
63
+ renameDraft.value = currentTitle
64
+ }
65
+
66
+ function commitRename() {
67
+ if (editingTabId.value) {
68
+ renameTab(editingTabId.value, renameDraft.value)
69
+ }
70
+ editingTabId.value = null
71
+ }
72
+
73
+ const toast = useToast()
74
+
75
+ function tryNewTab() {
76
+ if (capReached.value) {
77
+ toast.add({
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',
82
+ })
83
+ return
84
+ }
85
+ newTab()
86
+ }
87
+
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)
110
+ }
111
+
112
+ // First-visit: open one tab automatically.
113
+ onMounted(() => {
114
+ if (tabs.value.length === 0) newTab()
115
+ })
116
+
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)
13
123
  </script>
14
124
 
15
125
  <template>
16
126
  <div class="flex flex-col gap-3 h-full">
17
- <header class="flex items-center justify-between">
127
+ <header class="flex items-center justify-between gap-3 flex-wrap">
18
128
  <div>
19
129
  <h1 class="text-2xl font-semibold">Terminal</h1>
20
130
  <p class="text-sm text-muted">
21
- Real shell, PTY-backed. Runs the same commands as your local zsh —
22
- claude, codex, git, anything. Session ends when you leave this page.
131
+ Real PTY shell run claude, codex, git, anything. ⌘T new ·
132
+ ⌘W close · ⌘1–8 switch.
23
133
  </p>
24
134
  </div>
25
135
  <div class="flex items-center gap-2">
@@ -30,22 +140,106 @@ const expanded = ref(false)
30
140
  <UButton
31
141
  size="xs"
32
142
  variant="ghost"
33
- :icon="expanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
34
- @click="expanded = !expanded"
143
+ :icon="showHistory ? 'i-lucide-x' : 'i-lucide-history'"
144
+ @click="showHistory = !showHistory"
35
145
  >
36
- {{ expanded ? 'Restore' : 'Expand' }}
146
+ History ({{ history.length }})
37
147
  </UButton>
38
148
  </div>
39
149
  </header>
40
150
 
41
- <Terminal
42
- ref="terminalRef"
43
- :class="expanded ? 'fixed inset-4 z-40' : 'flex-1 min-h-[520px]'"
44
- />
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
+ />
208
+ </template>
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"
219
+ >
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>
236
+ </div>
237
+ </aside>
238
+ </div>
45
239
 
46
240
  <footer class="text-xs text-muted">
47
- Multi-session tabs, command history search and theme picker arrive in
48
- PR99c / PR99d. PR99a (backend PTY) is live.
241
+ Sessions live on the backend until you close them or 30 min idle.
242
+ History stays in this browser only.
49
243
  </footer>
50
244
  </div>
51
245
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.68.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.68.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"}