arkaos 3.68.0 → 3.70.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.70.0
@@ -1,47 +1,35 @@
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'
10
9
  import { WebLinksAddon } from '@xterm/addon-web-links'
11
10
  import { SearchAddon } from '@xterm/addon-search'
12
11
  import '@xterm/xterm/css/xterm.css'
12
+ import type { XtermTheme } from '~/composables/useTerminalThemes'
13
+
14
+ interface Props {
15
+ session?: ReturnType<typeof useTerminalSession>
16
+ onInputLine?: (line: string) => void
17
+ theme?: XtermTheme
18
+ }
19
+ const props = defineProps<Props>()
13
20
 
14
21
  const container = ref<HTMLDivElement | null>(null)
15
- const session = useTerminalSession()
22
+ const session = props.session ?? useTerminalSession()
16
23
  const term = shallowRef<XTerm | null>(null)
17
24
  const fit = shallowRef<FitAddon | null>(null)
18
25
  const search = shallowRef<SearchAddon | null>(null)
19
26
 
20
27
  const decoder = new TextDecoder('utf-8', { fatal: false })
21
28
 
22
- const themeArkaOSDark = {
23
- background: '#0a0a0f',
24
- foreground: '#e6e6f0',
25
- cursor: '#7dd3fc',
26
- cursorAccent: '#0a0a0f',
27
- selectionBackground: '#1e3a5f',
28
- black: '#0a0a0f',
29
- red: '#f87171',
30
- green: '#86efac',
31
- yellow: '#fde68a',
32
- blue: '#7dd3fc',
33
- magenta: '#f0abfc',
34
- cyan: '#67e8f9',
35
- white: '#e6e6f0',
36
- brightBlack: '#3f3f46',
37
- brightRed: '#fca5a5',
38
- brightGreen: '#bbf7d0',
39
- brightYellow: '#fef3c7',
40
- brightBlue: '#bae6fd',
41
- brightMagenta: '#f5d0fe',
42
- brightCyan: '#a5f3fc',
43
- brightWhite: '#fafafa',
44
- }
29
+ // PR99d v3.70.0 — theme comes from prop or from the composable
30
+ // default (operator's choice stored in localStorage).
31
+ const { activeTheme } = useTerminalThemes()
32
+ const effectiveTheme = computed(() => props.theme ?? activeTheme.value)
45
33
 
46
34
  let unsubscribeOutput: (() => void) | null = null
47
35
  let resizeObserver: ResizeObserver | null = null
@@ -55,9 +43,14 @@ onMounted(async () => {
55
43
  fontSize: 13,
56
44
  lineHeight: 1.2,
57
45
  scrollback: 5000,
58
- theme: themeArkaOSDark,
46
+ theme: effectiveTheme.value,
59
47
  allowProposedApi: true,
60
48
  })
49
+
50
+ // React to theme switches without remounting.
51
+ watch(effectiveTheme, (next) => {
52
+ if (term.value) term.value.options.theme = next
53
+ }, { deep: true })
61
54
  const fitAddon = new FitAddon()
62
55
  const searchAddon = new SearchAddon()
63
56
  t.loadAddon(fitAddon)
@@ -77,7 +70,22 @@ onMounted(async () => {
77
70
  t.write(text)
78
71
  })
79
72
 
73
+ // PR99c v3.69.0 — line-buffer for command history without server-
74
+ // side audit. Captures only printable chars up to Enter; ignores
75
+ // arrow keys, ctrl combos, escape sequences.
76
+ let lineBuf = ''
80
77
  t.onData((data) => {
78
+ for (const ch of data) {
79
+ if (ch === '\r' || ch === '\n') {
80
+ const cmd = lineBuf.trim()
81
+ if (cmd) props.onInputLine?.(cmd)
82
+ lineBuf = ''
83
+ } else if (ch === '\x7f' || ch === '\b') {
84
+ lineBuf = lineBuf.slice(0, -1)
85
+ } else if (ch >= ' ' && ch < '\x7f') {
86
+ lineBuf += ch
87
+ }
88
+ }
81
89
  session.sendInput(data)
82
90
  })
83
91
 
@@ -103,7 +111,11 @@ onMounted(async () => {
103
111
  onBeforeUnmount(async () => {
104
112
  unsubscribeOutput?.()
105
113
  resizeObserver?.disconnect()
106
- await session.close()
114
+ // PR99c: only close the session if we created it. When the parent
115
+ // owns the session (props.session), parent is responsible for close.
116
+ if (!props.session) {
117
+ await session.close()
118
+ }
107
119
  term.value?.dispose()
108
120
  })
109
121
 
@@ -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
+ }
@@ -0,0 +1,190 @@
1
+ // PR99d v3.70.0 — theme presets for xterm.js.
2
+ //
3
+ // Stored as plain xterm.js ITheme objects. The active theme name lives
4
+ // in localStorage so it survives reloads. ArkaOS Dark is the default
5
+ // and is tuned to the dashboard's primary color.
6
+
7
+ export interface XtermTheme {
8
+ background: string
9
+ foreground: string
10
+ cursor: string
11
+ cursorAccent: string
12
+ selectionBackground: string
13
+ black: string
14
+ red: string
15
+ green: string
16
+ yellow: string
17
+ blue: string
18
+ magenta: string
19
+ cyan: string
20
+ white: string
21
+ brightBlack: string
22
+ brightRed: string
23
+ brightGreen: string
24
+ brightYellow: string
25
+ brightBlue: string
26
+ brightMagenta: string
27
+ brightCyan: string
28
+ brightWhite: string
29
+ }
30
+
31
+ export const TERMINAL_THEMES: Record<string, XtermTheme> = {
32
+ 'arkaos-dark': {
33
+ background: '#0a0a0f',
34
+ foreground: '#e6e6f0',
35
+ cursor: '#7dd3fc',
36
+ cursorAccent: '#0a0a0f',
37
+ selectionBackground: '#1e3a5f',
38
+ black: '#0a0a0f',
39
+ red: '#f87171',
40
+ green: '#86efac',
41
+ yellow: '#fde68a',
42
+ blue: '#7dd3fc',
43
+ magenta: '#f0abfc',
44
+ cyan: '#67e8f9',
45
+ white: '#e6e6f0',
46
+ brightBlack: '#3f3f46',
47
+ brightRed: '#fca5a5',
48
+ brightGreen: '#bbf7d0',
49
+ brightYellow: '#fef3c7',
50
+ brightBlue: '#bae6fd',
51
+ brightMagenta: '#f5d0fe',
52
+ brightCyan: '#a5f3fc',
53
+ brightWhite: '#fafafa',
54
+ },
55
+ dracula: {
56
+ background: '#282a36',
57
+ foreground: '#f8f8f2',
58
+ cursor: '#f8f8f2',
59
+ cursorAccent: '#282a36',
60
+ selectionBackground: '#44475a',
61
+ black: '#21222c',
62
+ red: '#ff5555',
63
+ green: '#50fa7b',
64
+ yellow: '#f1fa8c',
65
+ blue: '#bd93f9',
66
+ magenta: '#ff79c6',
67
+ cyan: '#8be9fd',
68
+ white: '#f8f8f2',
69
+ brightBlack: '#6272a4',
70
+ brightRed: '#ff6e6e',
71
+ brightGreen: '#69ff94',
72
+ brightYellow: '#ffffa5',
73
+ brightBlue: '#d6acff',
74
+ brightMagenta: '#ff92df',
75
+ brightCyan: '#a4ffff',
76
+ brightWhite: '#ffffff',
77
+ },
78
+ 'solarized-dark': {
79
+ background: '#002b36',
80
+ foreground: '#839496',
81
+ cursor: '#93a1a1',
82
+ cursorAccent: '#002b36',
83
+ selectionBackground: '#073642',
84
+ black: '#073642',
85
+ red: '#dc322f',
86
+ green: '#859900',
87
+ yellow: '#b58900',
88
+ blue: '#268bd2',
89
+ magenta: '#d33682',
90
+ cyan: '#2aa198',
91
+ white: '#eee8d5',
92
+ brightBlack: '#586e75',
93
+ brightRed: '#cb4b16',
94
+ brightGreen: '#586e75',
95
+ brightYellow: '#657b83',
96
+ brightBlue: '#839496',
97
+ brightMagenta: '#6c71c4',
98
+ brightCyan: '#93a1a1',
99
+ brightWhite: '#fdf6e3',
100
+ },
101
+ 'solarized-light': {
102
+ background: '#fdf6e3',
103
+ foreground: '#657b83',
104
+ cursor: '#586e75',
105
+ cursorAccent: '#fdf6e3',
106
+ selectionBackground: '#eee8d5',
107
+ black: '#073642',
108
+ red: '#dc322f',
109
+ green: '#859900',
110
+ yellow: '#b58900',
111
+ blue: '#268bd2',
112
+ magenta: '#d33682',
113
+ cyan: '#2aa198',
114
+ white: '#eee8d5',
115
+ brightBlack: '#002b36',
116
+ brightRed: '#cb4b16',
117
+ brightGreen: '#586e75',
118
+ brightYellow: '#657b83',
119
+ brightBlue: '#839496',
120
+ brightMagenta: '#6c71c4',
121
+ brightCyan: '#93a1a1',
122
+ brightWhite: '#fdf6e3',
123
+ },
124
+ nord: {
125
+ background: '#2e3440',
126
+ foreground: '#d8dee9',
127
+ cursor: '#d8dee9',
128
+ cursorAccent: '#2e3440',
129
+ selectionBackground: '#434c5e',
130
+ black: '#3b4252',
131
+ red: '#bf616a',
132
+ green: '#a3be8c',
133
+ yellow: '#ebcb8b',
134
+ blue: '#81a1c1',
135
+ magenta: '#b48ead',
136
+ cyan: '#88c0d0',
137
+ white: '#e5e9f0',
138
+ brightBlack: '#4c566a',
139
+ brightRed: '#bf616a',
140
+ brightGreen: '#a3be8c',
141
+ brightYellow: '#ebcb8b',
142
+ brightBlue: '#81a1c1',
143
+ brightMagenta: '#b48ead',
144
+ brightCyan: '#8fbcbb',
145
+ brightWhite: '#eceff4',
146
+ },
147
+ }
148
+
149
+ export const THEME_LABELS: Record<string, string> = {
150
+ 'arkaos-dark': 'ArkaOS Dark',
151
+ dracula: 'Dracula',
152
+ 'solarized-dark': 'Solarized Dark',
153
+ 'solarized-light': 'Solarized Light',
154
+ nord: 'Nord',
155
+ }
156
+
157
+ const STORAGE_KEY = 'arka-terminal-theme'
158
+ const DEFAULT_THEME = 'arkaos-dark'
159
+
160
+ export function useTerminalThemes() {
161
+ const themeName = useState<string>('terminal-theme', () => {
162
+ if (typeof localStorage === 'undefined') return DEFAULT_THEME
163
+ return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME
164
+ })
165
+
166
+ function setTheme(name: string) {
167
+ if (!TERMINAL_THEMES[name]) return
168
+ themeName.value = name
169
+ try {
170
+ localStorage.setItem(STORAGE_KEY, name)
171
+ } catch {
172
+ // ignore quota
173
+ }
174
+ }
175
+
176
+ const activeTheme = computed<XtermTheme>(
177
+ () => TERMINAL_THEMES[themeName.value] ?? TERMINAL_THEMES[DEFAULT_THEME]!,
178
+ )
179
+
180
+ const options = computed(() =>
181
+ Object.entries(THEME_LABELS).map(([value, label]) => ({ value, label })),
182
+ )
183
+
184
+ return {
185
+ themeName,
186
+ activeTheme,
187
+ setTheme,
188
+ options,
189
+ }
190
+ }
@@ -1,25 +1,159 @@
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
+ // PR99d v3.70.0 — theme picker + Ctrl+R history search.
59
+ const { themeName, setTheme, options: themeOptions } = useTerminalThemes()
60
+ const searchOpen = ref(false)
61
+ const searchQuery = ref('')
62
+
63
+ const searchResults = computed(() => {
64
+ const q = searchQuery.value.trim().toLowerCase()
65
+ if (!q) return history.value.slice(0, 30)
66
+ return history.value
67
+ .filter((h) => h.cmd.toLowerCase().includes(q))
68
+ .slice(0, 30)
69
+ })
70
+
71
+ function openSearch() {
72
+ searchOpen.value = true
73
+ searchQuery.value = ''
74
+ }
75
+
76
+ function pickFromSearch(cmd: string) {
77
+ activeTab.value?.session.sendInput(cmd)
78
+ searchOpen.value = false
79
+ }
80
+
81
+ const editingTabId = ref<string | null>(null)
82
+ const renameDraft = ref('')
83
+
84
+ function startRename(tabId: string, currentTitle: string) {
85
+ editingTabId.value = tabId
86
+ renameDraft.value = currentTitle
87
+ }
88
+
89
+ function commitRename() {
90
+ if (editingTabId.value) {
91
+ renameTab(editingTabId.value, renameDraft.value)
92
+ }
93
+ editingTabId.value = null
94
+ }
95
+
96
+ const toast = useToast()
97
+
98
+ function tryNewTab() {
99
+ if (capReached.value) {
100
+ toast.add({
101
+ title: 'Maximum sessions reached',
102
+ description: `You can have up to ${maxTabs} sessions open at once. Close one to open a new one.`,
103
+ color: 'warning',
104
+ icon: 'i-lucide-alert-triangle',
105
+ })
106
+ return
107
+ }
108
+ newTab()
109
+ }
110
+
111
+ // Keyboard shortcuts.
112
+ defineShortcuts({
113
+ meta_t: { handler: tryNewTab, usingInput: false },
114
+ meta_w: {
115
+ handler: () => {
116
+ if (activeId.value) closeTab(activeId.value)
117
+ },
118
+ usingInput: false,
119
+ },
120
+ ctrl_r: { handler: openSearch, usingInput: false },
121
+ meta_1: { handler: () => switchByIndex(0), usingInput: false },
122
+ meta_2: { handler: () => switchByIndex(1), usingInput: false },
123
+ meta_3: { handler: () => switchByIndex(2), usingInput: false },
124
+ meta_4: { handler: () => switchByIndex(3), usingInput: false },
125
+ meta_5: { handler: () => switchByIndex(4), usingInput: false },
126
+ meta_6: { handler: () => switchByIndex(5), usingInput: false },
127
+ meta_7: { handler: () => switchByIndex(6), usingInput: false },
128
+ meta_8: { handler: () => switchByIndex(7), usingInput: false },
129
+ })
130
+
131
+ function switchByIndex(idx: number) {
132
+ const t = tabs.value[idx]
133
+ if (t) switchTab(t.id)
134
+ }
135
+
136
+ // First-visit: open one tab automatically.
137
+ onMounted(() => {
138
+ if (tabs.value.length === 0) newTab()
139
+ })
140
+
141
+ onBeforeUnmount(async () => {
142
+ // Don't proactively close tabs — operator may navigate back. Backend
143
+ // reaper will GC after the idle timeout (30 min).
144
+ })
145
+
146
+ const showHistory = ref(false)
13
147
  </script>
14
148
 
15
149
  <template>
16
150
  <div class="flex flex-col gap-3 h-full">
17
- <header class="flex items-center justify-between">
151
+ <header class="flex items-center justify-between gap-3 flex-wrap">
18
152
  <div>
19
153
  <h1 class="text-2xl font-semibold">Terminal</h1>
20
154
  <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.
155
+ Real PTY shell run claude, codex, git, anything. ⌘T new ·
156
+ ⌘W close · ⌘1–8 switch.
23
157
  </p>
24
158
  </div>
25
159
  <div class="flex items-center gap-2">
@@ -27,25 +161,155 @@ const expanded = ref(false)
27
161
  <UIcon name="i-lucide-shield" class="size-3 mr-1" />
28
162
  localhost only
29
163
  </UBadge>
164
+ <USelect
165
+ :model-value="themeName"
166
+ :items="themeOptions"
167
+ size="xs"
168
+ class="w-44"
169
+ @update:model-value="setTheme($event as string)"
170
+ />
171
+ <UButton
172
+ size="xs"
173
+ variant="ghost"
174
+ icon="i-lucide-search"
175
+ title="Ctrl+R — search history"
176
+ @click="openSearch"
177
+ >
178
+ ⌃R
179
+ </UButton>
30
180
  <UButton
31
181
  size="xs"
32
182
  variant="ghost"
33
- :icon="expanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
34
- @click="expanded = !expanded"
183
+ :icon="showHistory ? 'i-lucide-x' : 'i-lucide-history'"
184
+ @click="showHistory = !showHistory"
35
185
  >
36
- {{ expanded ? 'Restore' : 'Expand' }}
186
+ History ({{ history.length }})
37
187
  </UButton>
38
188
  </div>
39
189
  </header>
40
190
 
41
- <Terminal
42
- ref="terminalRef"
43
- :class="expanded ? 'fixed inset-4 z-40' : 'flex-1 min-h-[520px]'"
44
- />
191
+ <div class="flex items-center gap-1 border-b border-default pb-2 overflow-x-auto">
192
+ <div
193
+ v-for="(tab, idx) in tabs"
194
+ :key="tab.id"
195
+ :class="[
196
+ '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',
197
+ activeId === tab.id
198
+ ? 'bg-elevated/60 border-primary text-default'
199
+ : 'border-transparent text-muted hover:text-default hover:bg-elevated/30',
200
+ ]"
201
+ @click="switchTab(tab.id)"
202
+ @dblclick="startRename(tab.id, tab.title)"
203
+ >
204
+ <span class="text-xs text-muted">{{ idx + 1 }}</span>
205
+ <UInput
206
+ v-if="editingTabId === tab.id"
207
+ v-model="renameDraft"
208
+ size="xs"
209
+ autofocus
210
+ @keydown.enter="commitRename"
211
+ @keydown.esc="editingTabId = null"
212
+ @blur="commitRename"
213
+ />
214
+ <span v-else>{{ tab.title }}</span>
215
+ <UIcon
216
+ v-if="tab.hasActivity && activeId !== tab.id"
217
+ name="i-lucide-circle"
218
+ class="size-2 text-amber-400 fill-current"
219
+ />
220
+ <button
221
+ 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"
222
+ @click.stop="closeTab(tab.id)"
223
+ >
224
+ <UIcon name="i-lucide-x" class="size-3" />
225
+ </button>
226
+ </div>
227
+ <UButton
228
+ size="xs"
229
+ variant="ghost"
230
+ icon="i-lucide-plus"
231
+ :disabled="capReached"
232
+ :title="capReached ? `Max ${maxTabs} sessions` : 'New session'"
233
+ @click="tryNewTab"
234
+ >
235
+ New
236
+ </UButton>
237
+ </div>
238
+
239
+ <div class="flex-1 min-h-[480px] flex gap-3">
240
+ <div class="flex-1 relative">
241
+ <template v-for="tab in tabs" :key="tab.id">
242
+ <Terminal
243
+ v-show="activeId === tab.id"
244
+ :session="tab.session"
245
+ :on-input-line="recordCommand"
246
+ class="absolute inset-0"
247
+ />
248
+ </template>
249
+ <div
250
+ v-if="tabs.length === 0"
251
+ class="absolute inset-0 grid place-items-center text-muted text-sm"
252
+ >
253
+ No active sessions. Press ⌘T or click "+ New" to open one.
254
+ </div>
255
+ </div>
256
+ <aside
257
+ v-if="showHistory"
258
+ class="w-72 shrink-0 rounded-lg border border-default bg-elevated/10 overflow-hidden flex flex-col"
259
+ >
260
+ <div class="px-3 py-2 border-b border-default text-xs uppercase tracking-wide text-muted">
261
+ Command history
262
+ </div>
263
+ <div class="flex-1 overflow-auto text-xs font-mono">
264
+ <button
265
+ v-for="(entry, i) in history"
266
+ :key="i"
267
+ class="w-full text-left px-3 py-1.5 hover:bg-default/40 truncate"
268
+ :title="entry.cmd"
269
+ @click="activeTab?.session.sendInput(entry.cmd)"
270
+ >
271
+ {{ entry.cmd }}
272
+ </button>
273
+ <div v-if="history.length === 0" class="px-3 py-4 text-muted text-center">
274
+ No commands yet
275
+ </div>
276
+ </div>
277
+ </aside>
278
+ </div>
45
279
 
46
280
  <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.
281
+ Sessions live on the backend until you close them or 30 min idle.
282
+ History stays in this browser only. Ctrl+R to search history.
49
283
  </footer>
284
+
285
+ <UModal v-model:open="searchOpen" :title="`Search history (${history.length})`">
286
+ <template #body>
287
+ <div class="space-y-2">
288
+ <UInput
289
+ v-model="searchQuery"
290
+ placeholder="type to filter…"
291
+ autofocus
292
+ icon="i-lucide-search"
293
+ @keydown.enter="searchResults[0] && pickFromSearch(searchResults[0].cmd)"
294
+ />
295
+ <div class="max-h-80 overflow-y-auto rounded-md border border-default divide-y divide-default">
296
+ <button
297
+ v-for="(entry, i) in searchResults"
298
+ :key="i"
299
+ class="w-full text-left px-3 py-2 text-sm font-mono hover:bg-elevated/40 truncate"
300
+ @click="pickFromSearch(entry.cmd)"
301
+ >
302
+ {{ entry.cmd }}
303
+ </button>
304
+ <div v-if="searchResults.length === 0" class="px-3 py-4 text-muted text-center text-sm">
305
+ No matches
306
+ </div>
307
+ </div>
308
+ <p class="text-xs text-muted">
309
+ Enter sends the top match to the active session.
310
+ </p>
311
+ </div>
312
+ </template>
313
+ </UModal>
50
314
  </div>
51
315
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.68.0",
3
+ "version": "3.70.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.70.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"}
@@ -2221,216 +2221,8 @@ def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
2221
2221
  return None
2222
2222
 
2223
2223
 
2224
- # --- Terminal command runner (PR95a v3.51.0) ---
2225
-
2226
- # Allowlist of commands the dashboard terminal can run. Each entry is
2227
- # {id, label, cmd, description}. Server-side enforcement: any request
2228
- # whose `command_id` isn't in this list is rejected. No shell expansion,
2229
- # no globbing, no pipes — subprocess.run with explicit argv only.
2230
- TERMINAL_ALLOWLIST: list[dict] = [
2231
- {
2232
- "id": "arka-status",
2233
- "label": "ArkaOS status",
2234
- "cmd": ["arkaos", "status"],
2235
- "description": "System status (version, departments, agents, projects).",
2236
- },
2237
- {
2238
- "id": "git-status",
2239
- "label": "git status",
2240
- "cmd": ["git", "status", "--short"],
2241
- "description": "Working tree status in short form.",
2242
- },
2243
- {
2244
- "id": "git-log",
2245
- "label": "git log",
2246
- "cmd_template": ["git", "log", "-{count}", "--oneline"],
2247
- "args": [
2248
- {
2249
- "name": "count",
2250
- "label": "Commits to show",
2251
- "choices": ["5", "10", "20", "50"],
2252
- "default": "10",
2253
- },
2254
- ],
2255
- "description": "Most recent N commits (operator picks N).",
2256
- },
2257
- {
2258
- "id": "npm-version",
2259
- "label": "npm view arkaos version",
2260
- "cmd": ["npm", "view", "arkaos", "version"],
2261
- "description": "Latest published ArkaOS version on npm.",
2262
- },
2263
- {
2264
- "id": "pytest-collect",
2265
- "label": "pytest --collect-only",
2266
- "cmd": ["python3", "-m", "pytest", "--collect-only", "-q", "tests/python/"],
2267
- "description": "List every Python test without running.",
2268
- },
2269
- {
2270
- "id": "ls",
2271
- "label": "ls",
2272
- "cmd": ["ls", "-la"],
2273
- "description": "List files in the project root.",
2274
- },
2275
- {
2276
- "id": "arka-costs",
2277
- "label": "ArkaOS costs",
2278
- "cmd_template": ["python3", "-m", "core.runtime.llm_cost_telemetry_cli", "{period}"],
2279
- "args": [
2280
- {
2281
- "name": "period",
2282
- "label": "Period",
2283
- "choices": ["today", "week", "month", "all"],
2284
- "default": "today",
2285
- },
2286
- ],
2287
- "description": "LLM cost summary for the selected period.",
2288
- },
2289
- ]
2290
-
2291
- _TERMINAL_TIMEOUT_S = 15
2292
- _TERMINAL_MAX_OUTPUT = 20_000 # chars — both stdout and stderr capped
2293
-
2294
-
2295
- @app.get("/api/terminal/commands")
2296
- def terminal_commands():
2297
- """List allowlisted commands the dashboard terminal may run.
2298
-
2299
- PR96b v3.56.0 — includes the `args` schema (label / choices /
2300
- default) when an entry has parameters. The raw cmd / cmd_template
2301
- is NEVER returned — defence in depth.
2302
- """
2303
- out: list[dict] = []
2304
- for c in TERMINAL_ALLOWLIST:
2305
- out.append({
2306
- "id": c["id"],
2307
- "label": c["label"],
2308
- "description": c["description"],
2309
- "args": _safe_args_schema(c.get("args")),
2310
- })
2311
- return {"commands": out, "total": len(out)}
2312
-
2313
-
2314
- def _safe_args_schema(args) -> list[dict]:
2315
- """Sanitised arg schema for the public API — never leaks cmd/template."""
2316
- if not isinstance(args, list):
2317
- return []
2318
- out: list[dict] = []
2319
- for a in args:
2320
- if not isinstance(a, dict):
2321
- continue
2322
- choices = a.get("choices")
2323
- if not isinstance(choices, list) or not choices:
2324
- continue
2325
- out.append({
2326
- "name": str(a.get("name") or ""),
2327
- "label": str(a.get("label") or a.get("name") or ""),
2328
- "choices": [str(x) for x in choices],
2329
- "default": str(a.get("default") or choices[0]),
2330
- })
2331
- return out
2332
-
2333
-
2334
- @app.post("/api/terminal/exec")
2335
- def terminal_exec(body: dict):
2336
- """PR95a v3.51.0 — run an allowlisted command and return the output.
2337
-
2338
- Body: ``{"command_id": "<id>"}``. Unknown ids are rejected.
2339
- Returns ``{stdout, stderr, exit_code, duration_ms, command}``.
2340
- Output is capped at ``_TERMINAL_MAX_OUTPUT`` chars per stream.
2341
- """
2342
- if not isinstance(body, dict):
2343
- return {"error": "body must be an object"}
2344
- cid = (body.get("command_id") or "").strip()
2345
- if not cid:
2346
- return {"error": "command_id is required"}
2347
- entry = next((c for c in TERMINAL_ALLOWLIST if c["id"] == cid), None)
2348
- if entry is None:
2349
- return {"error": f"command '{cid}' is not on the allowlist"}
2350
-
2351
- # PR96b v3.56.0 — resolve template + args, or fall back to fixed cmd.
2352
- if "cmd_template" in entry:
2353
- argv, err = _resolve_cmd_template(entry, body.get("args") or {})
2354
- if err:
2355
- return {"error": err}
2356
- else:
2357
- argv = entry["cmd"]
2358
-
2359
- import time
2360
- started = time.monotonic()
2361
- try:
2362
- result = subprocess.run(
2363
- argv,
2364
- cwd=str(ARKAOS_ROOT),
2365
- capture_output=True,
2366
- text=True,
2367
- timeout=_TERMINAL_TIMEOUT_S,
2368
- shell=False,
2369
- )
2370
- except subprocess.TimeoutExpired:
2371
- return {
2372
- "stdout": "",
2373
- "stderr": f"command timed out after {_TERMINAL_TIMEOUT_S}s",
2374
- "exit_code": -1,
2375
- "duration_ms": int(_TERMINAL_TIMEOUT_S * 1000),
2376
- "command": " ".join(argv),
2377
- }
2378
- except OSError as exc:
2379
- return {
2380
- "stdout": "",
2381
- "stderr": f"command failed to launch: {exc}",
2382
- "exit_code": -1,
2383
- "duration_ms": int((time.monotonic() - started) * 1000),
2384
- "command": " ".join(argv),
2385
- }
2386
- duration_ms = int((time.monotonic() - started) * 1000)
2387
- return {
2388
- "stdout": (result.stdout or "")[:_TERMINAL_MAX_OUTPUT],
2389
- "stderr": (result.stderr or "")[:_TERMINAL_MAX_OUTPUT],
2390
- "exit_code": result.returncode,
2391
- "duration_ms": duration_ms,
2392
- "command": " ".join(argv),
2393
- }
2394
-
2395
-
2396
- def _resolve_cmd_template(entry: dict, supplied: dict) -> tuple[list[str], "str | None"]:
2397
- """Substitute `{name}` placeholders in cmd_template with validated args.
2398
-
2399
- Returns (argv, None) on success, ([], error_msg) on validation failure.
2400
- Anything supplied that isn't in the schema's choices is rejected.
2401
- Unknown arg names are also rejected — no silent passthrough.
2402
- """
2403
- schema = entry.get("args") or []
2404
- if not isinstance(schema, list):
2405
- return [], "command has invalid args schema"
2406
- by_name: dict[str, dict] = {a["name"]: a for a in schema if isinstance(a, dict)}
2407
- chosen: dict[str, str] = {}
2408
- for arg_def in schema:
2409
- name = arg_def["name"]
2410
- s = supplied.get(name)
2411
- if s is None:
2412
- chosen[name] = str(arg_def.get("default") or arg_def["choices"][0])
2413
- continue
2414
- s_str = str(s)
2415
- if s_str not in arg_def["choices"]:
2416
- return [], (
2417
- f"arg '{name}'='{s_str}' is not in the allowed choices "
2418
- f"({', '.join(arg_def['choices'])})"
2419
- )
2420
- chosen[name] = s_str
2421
- for k in supplied:
2422
- if k not in by_name:
2423
- return [], f"unknown arg '{k}'"
2424
- argv: list[str] = []
2425
- for tok in entry["cmd_template"]:
2426
- if not isinstance(tok, str):
2427
- continue
2428
- out_tok = tok
2429
- for name, val in chosen.items():
2430
- out_tok = out_tok.replace(f"{{{name}}}", val)
2431
- argv.append(out_tok)
2432
- return argv, None
2433
-
2224
+ # Allowlist runner (PR95a v3.51.0) removed in PR99d v3.70.0 —
2225
+ # replaced by PTY WebSocket from PR99a/b/c.
2434
2226
 
2435
2227
  @app.put("/api/workflows/{workflow_id}/yaml")
2436
2228
  def workflow_update_yaml(workflow_id: str, body: dict):