arkaos 3.67.0 → 3.68.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.
|
|
1
|
+
3.68.0
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
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.
|
|
7
|
+
|
|
8
|
+
import { Terminal as XTerm } from '@xterm/xterm'
|
|
9
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
10
|
+
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
11
|
+
import { SearchAddon } from '@xterm/addon-search'
|
|
12
|
+
import '@xterm/xterm/css/xterm.css'
|
|
13
|
+
|
|
14
|
+
const container = ref<HTMLDivElement | null>(null)
|
|
15
|
+
const session = useTerminalSession()
|
|
16
|
+
const term = shallowRef<XTerm | null>(null)
|
|
17
|
+
const fit = shallowRef<FitAddon | null>(null)
|
|
18
|
+
const search = shallowRef<SearchAddon | null>(null)
|
|
19
|
+
|
|
20
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
21
|
+
|
|
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
|
+
}
|
|
45
|
+
|
|
46
|
+
let unsubscribeOutput: (() => void) | null = null
|
|
47
|
+
let resizeObserver: ResizeObserver | null = null
|
|
48
|
+
|
|
49
|
+
onMounted(async () => {
|
|
50
|
+
if (!container.value) return
|
|
51
|
+
|
|
52
|
+
const t = new XTerm({
|
|
53
|
+
cursorBlink: true,
|
|
54
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
55
|
+
fontSize: 13,
|
|
56
|
+
lineHeight: 1.2,
|
|
57
|
+
scrollback: 5000,
|
|
58
|
+
theme: themeArkaOSDark,
|
|
59
|
+
allowProposedApi: true,
|
|
60
|
+
})
|
|
61
|
+
const fitAddon = new FitAddon()
|
|
62
|
+
const searchAddon = new SearchAddon()
|
|
63
|
+
t.loadAddon(fitAddon)
|
|
64
|
+
t.loadAddon(new WebLinksAddon())
|
|
65
|
+
t.loadAddon(searchAddon)
|
|
66
|
+
t.open(container.value)
|
|
67
|
+
fitAddon.fit()
|
|
68
|
+
|
|
69
|
+
term.value = t
|
|
70
|
+
fit.value = fitAddon
|
|
71
|
+
search.value = searchAddon
|
|
72
|
+
|
|
73
|
+
await session.open()
|
|
74
|
+
|
|
75
|
+
unsubscribeOutput = session.onOutput((chunk) => {
|
|
76
|
+
const text = decoder.decode(chunk, { stream: true })
|
|
77
|
+
t.write(text)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
t.onData((data) => {
|
|
81
|
+
session.sendInput(data)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Initial size sync once the WS is open.
|
|
85
|
+
watch(session.status, (s) => {
|
|
86
|
+
if (s === 'open') {
|
|
87
|
+
const { cols, rows } = t
|
|
88
|
+
session.sendResize(cols, rows)
|
|
89
|
+
}
|
|
90
|
+
}, { immediate: true })
|
|
91
|
+
|
|
92
|
+
resizeObserver = new ResizeObserver(() => {
|
|
93
|
+
try {
|
|
94
|
+
fitAddon.fit()
|
|
95
|
+
session.sendResize(t.cols, t.rows)
|
|
96
|
+
} catch (_e) {
|
|
97
|
+
// dom may have unmounted
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
resizeObserver.observe(container.value)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
onBeforeUnmount(async () => {
|
|
104
|
+
unsubscribeOutput?.()
|
|
105
|
+
resizeObserver?.disconnect()
|
|
106
|
+
await session.close()
|
|
107
|
+
term.value?.dispose()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
defineExpose({
|
|
111
|
+
status: session.status,
|
|
112
|
+
error: session.error,
|
|
113
|
+
meta: session.meta,
|
|
114
|
+
})
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<div class="relative h-full w-full bg-[#0a0a0f] rounded-lg overflow-hidden border border-default">
|
|
119
|
+
<div
|
|
120
|
+
v-if="session.status.value === 'connecting'"
|
|
121
|
+
class="absolute inset-0 z-10 grid place-items-center text-muted text-sm bg-[#0a0a0f]/80 backdrop-blur"
|
|
122
|
+
>
|
|
123
|
+
<div class="flex items-center gap-2">
|
|
124
|
+
<UIcon name="i-lucide-loader" class="animate-spin size-4" />
|
|
125
|
+
Spawning PTY…
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div
|
|
129
|
+
v-else-if="session.status.value === 'error' || session.status.value === 'closed'"
|
|
130
|
+
class="absolute top-2 right-2 z-10 text-xs rounded-md bg-elevated/90 px-2 py-1 border border-default"
|
|
131
|
+
>
|
|
132
|
+
<span v-if="session.status.value === 'error'" class="text-red-400">
|
|
133
|
+
{{ session.error.value || 'error' }}
|
|
134
|
+
</span>
|
|
135
|
+
<span v-else class="text-muted">closed</span>
|
|
136
|
+
</div>
|
|
137
|
+
<div ref="container" class="absolute inset-0 p-2" />
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
:deep(.xterm) {
|
|
143
|
+
height: 100%;
|
|
144
|
+
width: 100%;
|
|
145
|
+
padding: 4px;
|
|
146
|
+
}
|
|
147
|
+
:deep(.xterm-viewport) {
|
|
148
|
+
background-color: transparent !important;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
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(): TerminalSessionHandle {
|
|
30
|
+
const { apiBase } = useApi()
|
|
31
|
+
const meta = ref<TerminalSessionMeta | null>(null)
|
|
32
|
+
const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
|
|
33
|
+
const error = ref<string | null>(null)
|
|
34
|
+
|
|
35
|
+
let ws: WebSocket | null = null
|
|
36
|
+
const listeners: Array<(chunk: Uint8Array) => void> = []
|
|
37
|
+
|
|
38
|
+
function wsUrl(path: string, token: string): string {
|
|
39
|
+
const base = apiBase.replace(/^http/, 'ws')
|
|
40
|
+
return `${base}${path}?token=${encodeURIComponent(token)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createSession(): Promise<TerminalSessionMeta> {
|
|
44
|
+
const r = await fetch(`${apiBase}/api/terminal/sessions`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ cols: 120, rows: 32 }),
|
|
48
|
+
})
|
|
49
|
+
if (!r.ok) {
|
|
50
|
+
const body = await r.text()
|
|
51
|
+
throw new Error(`create session failed: ${r.status} ${body}`)
|
|
52
|
+
}
|
|
53
|
+
return await r.json()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function open() {
|
|
57
|
+
if (status.value === 'open' || status.value === 'connecting') return
|
|
58
|
+
status.value = 'connecting'
|
|
59
|
+
error.value = null
|
|
60
|
+
try {
|
|
61
|
+
const m = await createSession()
|
|
62
|
+
meta.value = m
|
|
63
|
+
ws = new WebSocket(wsUrl(m.ws_path, m.token))
|
|
64
|
+
ws.binaryType = 'arraybuffer'
|
|
65
|
+
ws.onopen = () => {
|
|
66
|
+
status.value = 'open'
|
|
67
|
+
}
|
|
68
|
+
ws.onmessage = (ev) => {
|
|
69
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
70
|
+
const chunk = new Uint8Array(ev.data)
|
|
71
|
+
for (const cb of listeners) cb(chunk)
|
|
72
|
+
} else if (typeof ev.data === 'string') {
|
|
73
|
+
const enc = new TextEncoder().encode(ev.data)
|
|
74
|
+
for (const cb of listeners) cb(enc)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
ws.onerror = () => {
|
|
78
|
+
status.value = 'error'
|
|
79
|
+
error.value = 'websocket error'
|
|
80
|
+
}
|
|
81
|
+
ws.onclose = (ev) => {
|
|
82
|
+
status.value = 'closed'
|
|
83
|
+
if (ev.code !== 1000 && ev.code !== 1005) {
|
|
84
|
+
error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
status.value = 'error'
|
|
89
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sendInput(data: string) {
|
|
94
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
95
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendResize(cols: number, rows: number) {
|
|
99
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
100
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function close() {
|
|
104
|
+
try {
|
|
105
|
+
ws?.close(1000, 'client close')
|
|
106
|
+
} catch (_e) {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
const id = meta.value?.session_id
|
|
110
|
+
if (id) {
|
|
111
|
+
try {
|
|
112
|
+
await fetch(`${apiBase}/api/terminal/sessions/${id}`, { method: 'DELETE' })
|
|
113
|
+
} catch (_e) {
|
|
114
|
+
// ignore — best-effort cleanup; backend reaper will catch it
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
status.value = 'closed'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onOutput(cb: (chunk: Uint8Array) => void) {
|
|
121
|
+
listeners.push(cb)
|
|
122
|
+
return () => {
|
|
123
|
+
const idx = listeners.indexOf(cb)
|
|
124
|
+
if (idx >= 0) listeners.splice(idx, 1)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
meta,
|
|
130
|
+
status,
|
|
131
|
+
error,
|
|
132
|
+
open,
|
|
133
|
+
sendInput,
|
|
134
|
+
sendResize,
|
|
135
|
+
close,
|
|
136
|
+
onOutput,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,278 +1,51 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
2
|
+
// PR99b v3.68.0 — Real-shell terminal (single session).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
11
|
-
|
|
12
|
-
interface CommandArg {
|
|
13
|
-
name: string
|
|
14
|
-
label: string
|
|
15
|
-
choices: string[]
|
|
16
|
-
default: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface CommandEntry {
|
|
20
|
-
id: string
|
|
21
|
-
label: string
|
|
22
|
-
description: string
|
|
23
|
-
args?: CommandArg[]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ExecResult {
|
|
27
|
-
stdout: string
|
|
28
|
-
stderr: string
|
|
29
|
-
exit_code: number
|
|
30
|
-
duration_ms: number
|
|
31
|
-
command: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface HistoryEntry {
|
|
35
|
-
id: string
|
|
36
|
-
label: string
|
|
37
|
-
result: ExecResult
|
|
38
|
-
ts: string
|
|
39
|
-
}
|
|
40
|
-
|
|
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 ?? [])
|
|
48
|
-
|
|
49
|
-
const running = ref<string | null>(null)
|
|
50
|
-
const history = ref<HistoryEntry[]>([])
|
|
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.
|
|
51
8
|
|
|
52
|
-
|
|
53
|
-
// the operator's current selection for each arg.
|
|
54
|
-
const argState = reactive<Record<string, Record<string, string>>>({})
|
|
9
|
+
definePageMeta({ layout: 'default' })
|
|
55
10
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
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) {
|
|
105
|
-
toast.add({
|
|
106
|
-
title: 'Run failed',
|
|
107
|
-
description: err instanceof Error ? err.message : 'unknown error',
|
|
108
|
-
color: 'error',
|
|
109
|
-
})
|
|
110
|
-
} finally {
|
|
111
|
-
running.value = null
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
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' })
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function clearHistory() {
|
|
123
|
-
history.value = []
|
|
124
|
-
}
|
|
125
|
-
|
|
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
|
-
}
|
|
11
|
+
const terminalRef = ref<InstanceType<typeof import('~/components/Terminal.vue').default> | null>(null)
|
|
12
|
+
const expanded = ref(false)
|
|
136
13
|
</script>
|
|
137
14
|
|
|
138
15
|
<template>
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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>
|
|
274
|
-
</div>
|
|
275
|
-
</DashboardState>
|
|
276
|
-
</template>
|
|
277
|
-
</UDashboardPanel>
|
|
16
|
+
<div class="flex flex-col gap-3 h-full">
|
|
17
|
+
<header class="flex items-center justify-between">
|
|
18
|
+
<div>
|
|
19
|
+
<h1 class="text-2xl font-semibold">Terminal</h1>
|
|
20
|
+
<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.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-2">
|
|
26
|
+
<UBadge color="warning" variant="soft" size="sm">
|
|
27
|
+
<UIcon name="i-lucide-shield" class="size-3 mr-1" />
|
|
28
|
+
localhost only
|
|
29
|
+
</UBadge>
|
|
30
|
+
<UButton
|
|
31
|
+
size="xs"
|
|
32
|
+
variant="ghost"
|
|
33
|
+
:icon="expanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
|
|
34
|
+
@click="expanded = !expanded"
|
|
35
|
+
>
|
|
36
|
+
{{ expanded ? 'Restore' : 'Expand' }}
|
|
37
|
+
</UButton>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<Terminal
|
|
42
|
+
ref="terminalRef"
|
|
43
|
+
:class="expanded ? 'fixed inset-4 z-40' : 'flex-1 min-h-[520px]'"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<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.
|
|
49
|
+
</footer>
|
|
50
|
+
</div>
|
|
278
51
|
</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