jbs-client 0.0.1
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/.github/workflows/release.yml +54 -0
- package/README.md +61 -0
- package/build.ts +162 -0
- package/bun.lock +234 -0
- package/package.json +22 -0
- package/scripts/npm/README.md +14 -0
- package/scripts/npm/bin/jbs-client +70 -0
- package/scripts/npm/postinstall.mjs +62 -0
- package/src/app.tsx +239 -0
- package/src/components/action-menu.tsx +28 -0
- package/src/components/log-panel.tsx +31 -0
- package/src/components/param-form.tsx +73 -0
- package/src/components.test.tsx +36 -0
- package/src/index.tsx +19 -0
- package/src/protocol.test.ts +40 -0
- package/src/protocol.ts +201 -0
- package/src/state.test.ts +31 -0
- package/src/state.ts +77 -0
- package/src/types.ts +34 -0
- package/src/ws.ts +63 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const childProcess = require("child_process")
|
|
4
|
+
const fs = require("fs")
|
|
5
|
+
const path = require("path")
|
|
6
|
+
const os = require("os")
|
|
7
|
+
|
|
8
|
+
function run(target) {
|
|
9
|
+
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
})
|
|
12
|
+
if (result.error) {
|
|
13
|
+
console.error(result.error.message)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
process.exit(typeof result.status === "number" ? result.status : 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const envPath = process.env.JBS_CLIENT_BIN_PATH
|
|
20
|
+
if (envPath) {
|
|
21
|
+
run(envPath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scriptPath = fs.realpathSync(__filename)
|
|
25
|
+
const scriptDir = path.dirname(scriptPath)
|
|
26
|
+
const cached = path.join(scriptDir, ".jbs-client")
|
|
27
|
+
if (fs.existsSync(cached)) {
|
|
28
|
+
run(cached)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const platformMap = {
|
|
32
|
+
darwin: "darwin",
|
|
33
|
+
linux: "linux",
|
|
34
|
+
win32: "windows",
|
|
35
|
+
}
|
|
36
|
+
const archMap = {
|
|
37
|
+
x64: "x64",
|
|
38
|
+
arm64: "arm64",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const platform = platformMap[os.platform()] || os.platform()
|
|
42
|
+
const arch = archMap[os.arch()] || os.arch()
|
|
43
|
+
const packageName = `jbs-client-${platform}-${arch}`
|
|
44
|
+
const binaryName = platform === "windows" ? "jbs-client.exe" : "jbs-client"
|
|
45
|
+
|
|
46
|
+
function findBinary(startDir) {
|
|
47
|
+
let current = startDir
|
|
48
|
+
for (;;) {
|
|
49
|
+
const modules = path.join(current, "node_modules")
|
|
50
|
+
if (fs.existsSync(modules)) {
|
|
51
|
+
const candidate = path.join(modules, packageName, "bin", binaryName)
|
|
52
|
+
if (fs.existsSync(candidate)) {
|
|
53
|
+
return candidate
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const parent = path.dirname(current)
|
|
57
|
+
if (parent === current) {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
current = parent
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const resolved = findBinary(scriptDir)
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
console.error(`Unable to find installed binary package "${packageName}".`)
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
run(resolved)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
|
|
10
|
+
function detectPackageName() {
|
|
11
|
+
const platformMap = {
|
|
12
|
+
darwin: "darwin",
|
|
13
|
+
linux: "linux",
|
|
14
|
+
win32: "windows",
|
|
15
|
+
}
|
|
16
|
+
const archMap = {
|
|
17
|
+
x64: "x64",
|
|
18
|
+
arm64: "arm64",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const platform = platformMap[os.platform()] || os.platform()
|
|
22
|
+
const arch = archMap[os.arch()] || os.arch()
|
|
23
|
+
return {
|
|
24
|
+
packageName: `jbs-client-${platform}-${arch}`,
|
|
25
|
+
binaryName: platform === "windows" ? "jbs-client.exe" : "jbs-client",
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findBinary() {
|
|
30
|
+
const { packageName, binaryName } = detectPackageName()
|
|
31
|
+
const candidate = path.join(__dirname, "node_modules", packageName, "bin", binaryName)
|
|
32
|
+
if (!fs.existsSync(candidate)) {
|
|
33
|
+
throw new Error(`Missing binary package ${packageName}`)
|
|
34
|
+
}
|
|
35
|
+
return candidate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function main() {
|
|
39
|
+
if (os.platform() === "win32") {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const binaryPath = findBinary()
|
|
44
|
+
const cached = path.join(__dirname, "bin", ".jbs-client")
|
|
45
|
+
if (fs.existsSync(cached)) {
|
|
46
|
+
fs.unlinkSync(cached)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
fs.linkSync(binaryPath, cached)
|
|
51
|
+
} catch {
|
|
52
|
+
fs.copyFileSync(binaryPath, cached)
|
|
53
|
+
}
|
|
54
|
+
fs.chmodSync(cached, 0o755)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
main()
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
61
|
+
process.exit(0)
|
|
62
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react"
|
|
2
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { ActionMenu } from "./components/action-menu.js"
|
|
4
|
+
import { LogPanel } from "./components/log-panel.js"
|
|
5
|
+
import { ParamForm } from "./components/param-form.js"
|
|
6
|
+
import { buildPayload, menu, validateAction } from "./protocol.js"
|
|
7
|
+
import { appendLog, backToMenu, createInitialState, nextFocus, openAction, previousFocus, setConnectionStatus, updateFormValue, updateSelectedAction } from "./state.js"
|
|
8
|
+
import { connectWebSocket } from "./ws.js"
|
|
9
|
+
import type { AppState } from "./types.js"
|
|
10
|
+
import type { Selection } from "@opentui/core"
|
|
11
|
+
|
|
12
|
+
type AppProps = {
|
|
13
|
+
host: string
|
|
14
|
+
wsPort: number
|
|
15
|
+
connectBackend: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type MenuFocusTarget = "content" | "ws-input" | "reconnect"
|
|
19
|
+
|
|
20
|
+
export function App({ host, wsPort, connectBackend }: AppProps) {
|
|
21
|
+
const renderer = useRenderer()
|
|
22
|
+
const { width, height } = useTerminalDimensions()
|
|
23
|
+
const [state, setState] = useState(createInitialState)
|
|
24
|
+
const [validationError, setValidationError] = useState<string | null>(null)
|
|
25
|
+
const [wsUrl, setWsUrl] = useState(`ws://${host}:${wsPort}`)
|
|
26
|
+
const [reconnectVersion, setReconnectVersion] = useState(0)
|
|
27
|
+
const [menuFocusTarget, setMenuFocusTarget] = useState<MenuFocusTarget>("content")
|
|
28
|
+
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
|
29
|
+
|
|
30
|
+
const connection = useMemo(() => {
|
|
31
|
+
return connectWebSocket({
|
|
32
|
+
url: wsUrl,
|
|
33
|
+
enabled: connectBackend,
|
|
34
|
+
onLog: (message: string) => setState((current: AppState) => appendLog(current, message)),
|
|
35
|
+
onStatusChange: (status) => setState((current: AppState) => setConnectionStatus(current, status))
|
|
36
|
+
})
|
|
37
|
+
}, [connectBackend, reconnectVersion, wsUrl])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!connectBackend) {
|
|
41
|
+
setState((current: AppState) => appendLog(setConnectionStatus(current, "idle"), "[local] backend integration disabled"))
|
|
42
|
+
}
|
|
43
|
+
return () => connection.dispose()
|
|
44
|
+
}, [connectBackend, connection])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const handleSelection = (selection: Selection) => {
|
|
48
|
+
const text = selection.getSelectedText()
|
|
49
|
+
if (!text) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
renderer.copyToClipboardOSC52(text)
|
|
53
|
+
setToastMessage(`Copied ${text.length} chars to clipboard`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
renderer.on("selection", handleSelection)
|
|
57
|
+
return () => {
|
|
58
|
+
renderer.off("selection", handleSelection)
|
|
59
|
+
}
|
|
60
|
+
}, [renderer])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!toastMessage) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
setToastMessage(null)
|
|
68
|
+
}, 3000)
|
|
69
|
+
return () => clearTimeout(timer)
|
|
70
|
+
}, [toastMessage])
|
|
71
|
+
|
|
72
|
+
const reconnect = () => {
|
|
73
|
+
setValidationError(null)
|
|
74
|
+
setState((current: AppState) => appendLog(current, `[system] reconnect requested: ${wsUrl}`))
|
|
75
|
+
setReconnectVersion((value) => value + 1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cycleMenuFocus = (backward: boolean) => {
|
|
79
|
+
const order: MenuFocusTarget[] = ["ws-input", "reconnect", "content"]
|
|
80
|
+
const currentIndex = order.indexOf(menuFocusTarget)
|
|
81
|
+
const nextIndex = backward
|
|
82
|
+
? (currentIndex - 1 + order.length) % order.length
|
|
83
|
+
: (currentIndex + 1) % order.length
|
|
84
|
+
setMenuFocusTarget(order[nextIndex])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
useKeyboard((key) => {
|
|
88
|
+
if (key.ctrl && key.name === "c") {
|
|
89
|
+
renderer.destroy()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (state.screen === "menu") {
|
|
94
|
+
if (menuFocusTarget === "ws-input") {
|
|
95
|
+
if (key.name === "tab") {
|
|
96
|
+
cycleMenuFocus(Boolean(key.shift))
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (key.name === "escape") {
|
|
100
|
+
setMenuFocusTarget("content")
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (menuFocusTarget === "reconnect") {
|
|
106
|
+
if (key.name === "tab") {
|
|
107
|
+
cycleMenuFocus(Boolean(key.shift))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (key.name === "enter" || key.name === "return") {
|
|
111
|
+
reconnect()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (key.name === "escape") {
|
|
115
|
+
setMenuFocusTarget("content")
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (key.name === "up") {
|
|
121
|
+
setState((current: AppState) => updateSelectedAction(current, (current.selectedActionIndex - 1 + menu.length) % menu.length))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (key.name === "down") {
|
|
125
|
+
setState((current: AppState) => updateSelectedAction(current, (current.selectedActionIndex + 1) % menu.length))
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (key.name === "tab") {
|
|
129
|
+
cycleMenuFocus(Boolean(key.shift))
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (key.name === "enter" || key.name === "return") {
|
|
133
|
+
setValidationError(null)
|
|
134
|
+
setState((current: AppState) => openAction(current))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const inputCount = menu[state.selectedActionIndex].params.length
|
|
141
|
+
|
|
142
|
+
if (key.name === "escape") {
|
|
143
|
+
setValidationError(null)
|
|
144
|
+
setState((current: AppState) => backToMenu(current))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (key.name === "tab") {
|
|
148
|
+
setState((current: AppState) => nextFocus(current, inputCount))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (key.shift && key.name === "tab") {
|
|
152
|
+
setState((current: AppState) => previousFocus(current, inputCount))
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if ((key.name === "enter" || key.name === "return") && state.focusIndex === inputCount) {
|
|
156
|
+
if (!validateAction(state.selectedActionIndex, state.formValues)) {
|
|
157
|
+
setValidationError("Param Invalid")
|
|
158
|
+
setState((current: AppState) => appendLog(current, "Param Invalid"))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
setValidationError(null)
|
|
162
|
+
const payload = buildPayload(state.selectedActionIndex, state.formValues)
|
|
163
|
+
setState((current: AppState) => appendLog(current, `[request] ${payload}`))
|
|
164
|
+
connection.send(payload)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (width < 100 || height < 30) {
|
|
169
|
+
return <text>Window need to larger than 100x30, current={width}x{height}</text>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<box width="100%" height="100%" flexDirection="column" padding={1} gap={1} position="relative">
|
|
174
|
+
<box border borderColor="#7c3aed" paddingX={1} paddingY={0} flexDirection="row" alignItems="center" gap={1}>
|
|
175
|
+
<text>JBS Client OpenTUI</text>
|
|
176
|
+
</box>
|
|
177
|
+
{toastMessage ? (
|
|
178
|
+
<box position="absolute" top={1} right={2} zIndex={20} border borderColor="#3b82f6" backgroundColor="#172554" paddingX={1} paddingY={0}>
|
|
179
|
+
<text fg="#bfdbfe">{toastMessage}</text>
|
|
180
|
+
</box>
|
|
181
|
+
) : null}
|
|
182
|
+
<box border borderColor="#2563eb" height={3} minHeight={3} maxHeight={3} paddingX={1} paddingY={0} flexDirection="row" alignItems="center" justifyContent="flex-start" gap={1}>
|
|
183
|
+
<text fg="#93c5fd">WS</text>
|
|
184
|
+
<box flexGrow={1}>
|
|
185
|
+
<input value={wsUrl} onChange={setWsUrl} width="100%" focused={state.screen === "menu" && menuFocusTarget === "ws-input"} />
|
|
186
|
+
</box>
|
|
187
|
+
<box
|
|
188
|
+
border
|
|
189
|
+
borderColor={state.screen === "menu" && menuFocusTarget === "reconnect" ? "#3b82f6" : "#22c55e"}
|
|
190
|
+
paddingX={1}
|
|
191
|
+
paddingY={0}
|
|
192
|
+
flexGrow={0}
|
|
193
|
+
flexShrink={0}
|
|
194
|
+
alignSelf="center"
|
|
195
|
+
width={13}
|
|
196
|
+
focusable
|
|
197
|
+
onMouseDown={() => {
|
|
198
|
+
setMenuFocusTarget("reconnect")
|
|
199
|
+
reconnect()
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<text fg={state.screen === "menu" && menuFocusTarget === "reconnect" ? "#93c5fd" : "#22c55e"}>Reconnect</text>
|
|
203
|
+
</box>
|
|
204
|
+
<box flexGrow={0} flexShrink={0} width={12}>
|
|
205
|
+
<text fg={state.connectionStatus === "connected" ? "#22c55e" : state.connectionStatus === "error" ? "#f87171" : "#facc15"}>
|
|
206
|
+
{state.connectionStatus}
|
|
207
|
+
</text>
|
|
208
|
+
</box>
|
|
209
|
+
</box>
|
|
210
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
211
|
+
<box width="50%" flexGrow={1}>
|
|
212
|
+
{state.screen === "menu" ? (
|
|
213
|
+
<ActionMenu
|
|
214
|
+
focused={menuFocusTarget === "content"}
|
|
215
|
+
selectedIndex={state.selectedActionIndex}
|
|
216
|
+
onChange={(index: number) => setState((current: AppState) => updateSelectedAction(current, index))}
|
|
217
|
+
onSelect={(index: number) => {
|
|
218
|
+
setValidationError(null)
|
|
219
|
+
setState((current: AppState) => openAction(current, index))
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
) : (
|
|
223
|
+
<ParamForm
|
|
224
|
+
actionIndex={state.selectedActionIndex}
|
|
225
|
+
values={state.formValues}
|
|
226
|
+
focusIndex={state.focusIndex}
|
|
227
|
+
focused
|
|
228
|
+
validationError={validationError}
|
|
229
|
+
onChange={(index: number, value: string) => setState((current: AppState) => updateFormValue(current, index, value))}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
</box>
|
|
233
|
+
<box width="50%" flexGrow={1}>
|
|
234
|
+
<LogPanel logs={state.logs} focused={false} />
|
|
235
|
+
</box>
|
|
236
|
+
</box>
|
|
237
|
+
</box>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { menu } from "../protocol.js"
|
|
2
|
+
|
|
3
|
+
type ActionMenuProps = {
|
|
4
|
+
focused: boolean
|
|
5
|
+
selectedIndex: number
|
|
6
|
+
onChange: (index: number) => void
|
|
7
|
+
onSelect: (index: number) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ActionMenu({ focused, selectedIndex, onChange, onSelect }: ActionMenuProps) {
|
|
11
|
+
return (
|
|
12
|
+
<box border borderStyle="rounded" borderColor="#d946ef" padding={1} flexGrow={1} title="Input the action?">
|
|
13
|
+
<select
|
|
14
|
+
focused={focused}
|
|
15
|
+
height={19}
|
|
16
|
+
selectedIndex={selectedIndex}
|
|
17
|
+
options={menu.map((item) => ({
|
|
18
|
+
name: item.name,
|
|
19
|
+
value: item.name,
|
|
20
|
+
description: `${item.params.length} param${item.params.length === 1 ? "" : "s"}`
|
|
21
|
+
}))}
|
|
22
|
+
onChange={(index) => onChange(index)}
|
|
23
|
+
onSelect={(index) => onSelect(index)}
|
|
24
|
+
/>
|
|
25
|
+
<text fg="#9ca3af">Enter opens the form. Up and Down move the selection.</text>
|
|
26
|
+
</box>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
3
|
+
|
|
4
|
+
type LogPanelProps = {
|
|
5
|
+
logs: string[]
|
|
6
|
+
focused?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LogPanel({ logs, focused = false }: LogPanelProps) {
|
|
10
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!scrollRef.current) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
scrollRef.current.scrollTo({ x: 0, y: scrollRef.current.scrollHeight })
|
|
17
|
+
}, [logs])
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<box border borderStyle="rounded" borderColor="#26f7ce" padding={1} flexDirection="column" flexGrow={1} title="Logs">
|
|
21
|
+
<scrollbox ref={scrollRef} flexGrow={1} focused={focused} stickyScroll stickyStart="bottom">
|
|
22
|
+
<box flexDirection="column">
|
|
23
|
+
{logs.length === 0 ? <text fg="#6b7280" selectable>Waiting for logs...</text> : null}
|
|
24
|
+
{logs.map((log, index) => (
|
|
25
|
+
<text key={`${index}-${log}`} selectable>{log}</text>
|
|
26
|
+
))}
|
|
27
|
+
</box>
|
|
28
|
+
</scrollbox>
|
|
29
|
+
</box>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import { menu } from "../protocol.js"
|
|
3
|
+
|
|
4
|
+
type ParamFormProps = {
|
|
5
|
+
actionIndex: number
|
|
6
|
+
values: string[]
|
|
7
|
+
focusIndex: number
|
|
8
|
+
focused: boolean
|
|
9
|
+
validationError: string | null
|
|
10
|
+
onChange: (index: number, value: string) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ParamForm({ actionIndex, values, focusIndex, focused, validationError, onChange }: ParamFormProps) {
|
|
14
|
+
const action = menu[actionIndex]
|
|
15
|
+
const submitFocused = focused && focusIndex === action.params.length
|
|
16
|
+
const textareaRefs = useRef<Record<number, { plainText?: string; initialValue?: string } | null>>({})
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<box border borderStyle="rounded" borderColor="#d946ef" padding={1} flexDirection="column" gap={1} flexGrow={1} title={action.name}>
|
|
20
|
+
{action.params.length === 0 ? <text fg="#d1d5db">This action does not need parameters.</text> : null}
|
|
21
|
+
{action.params.map((param, index) => (
|
|
22
|
+
<box key={`${action.name}-${param.name}`} flexDirection="column" gap={1}>
|
|
23
|
+
<text fg="#a7f3d0">{param.name}</text>
|
|
24
|
+
{param.inputType === "textarea" ? (
|
|
25
|
+
<line-number minWidth={4} paddingRight={1} fg="#f472b6">
|
|
26
|
+
<textarea
|
|
27
|
+
ref={(instance) => {
|
|
28
|
+
textareaRefs.current[index] = instance
|
|
29
|
+
}}
|
|
30
|
+
initialValue={values[index] ?? ""}
|
|
31
|
+
height={12}
|
|
32
|
+
width="100%"
|
|
33
|
+
focused={focused && focusIndex === index}
|
|
34
|
+
wrapMode="word"
|
|
35
|
+
onContentChange={() => onChange(index, textareaRefs.current[index]?.plainText ?? values[index] ?? "")}
|
|
36
|
+
/>
|
|
37
|
+
</line-number>
|
|
38
|
+
) : param.inputType === "select" ? (
|
|
39
|
+
<select
|
|
40
|
+
options={(param.options ?? []).map((option) => ({
|
|
41
|
+
name: option.name,
|
|
42
|
+
description: option.description,
|
|
43
|
+
value: option.value
|
|
44
|
+
}))}
|
|
45
|
+
selectedIndex={Math.max(0, (param.options ?? []).findIndex((option) => option.value === (values[index] ?? param.value)))}
|
|
46
|
+
focused={focused && focusIndex === index}
|
|
47
|
+
height={4}
|
|
48
|
+
onChange={(selectedIndex, option) => onChange(index, String(option?.value ?? (param.options ?? [])[selectedIndex]?.value ?? param.value))}
|
|
49
|
+
/>
|
|
50
|
+
) : (
|
|
51
|
+
<input
|
|
52
|
+
value={values[index] ?? ""}
|
|
53
|
+
onChange={(value) => onChange(index, value)}
|
|
54
|
+
width="100%"
|
|
55
|
+
focused={focused && focusIndex === index}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</box>
|
|
59
|
+
))}
|
|
60
|
+
<box
|
|
61
|
+
border
|
|
62
|
+
borderStyle={submitFocused ? "double" : "single"}
|
|
63
|
+
borderColor={submitFocused ? "#22c55e" : "#6b7280"}
|
|
64
|
+
paddingX={2}
|
|
65
|
+
paddingY={1}
|
|
66
|
+
>
|
|
67
|
+
<text fg={submitFocused ? "#22c55e" : "#9ca3af"}>[ Submit ]</text>
|
|
68
|
+
</box>
|
|
69
|
+
{validationError ? <text fg="#f87171">{validationError}</text> : null}
|
|
70
|
+
<text fg="#9ca3af">Tab or Shift+Tab changes form focus. Esc returns to the action menu.</text>
|
|
71
|
+
</box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { testRender } from "@opentui/react/test-utils"
|
|
3
|
+
import { ActionMenu } from "./components/action-menu.js"
|
|
4
|
+
import { LogPanel } from "./components/log-panel.js"
|
|
5
|
+
|
|
6
|
+
let setup: Awaited<ReturnType<typeof testRender>> | undefined
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
setup?.renderer.destroy()
|
|
10
|
+
setup = undefined
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe("components", () => {
|
|
14
|
+
test("ActionMenu renders menu title and options", async () => {
|
|
15
|
+
setup = await testRender(<ActionMenu focused selectedIndex={0} onChange={() => {}} onSelect={() => {}} />, {
|
|
16
|
+
width: 70,
|
|
17
|
+
height: 20
|
|
18
|
+
})
|
|
19
|
+
await setup.renderOnce()
|
|
20
|
+
const frame = setup.captureCharFrame()
|
|
21
|
+
expect(frame).toContain("Input the action?")
|
|
22
|
+
expect(frame).toContain("Watch")
|
|
23
|
+
expect(frame).toContain("Eval")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("LogPanel renders logs and status", async () => {
|
|
27
|
+
setup = await testRender(<LogPanel logs={["hello", "world"]} />, {
|
|
28
|
+
width: 60,
|
|
29
|
+
height: 16
|
|
30
|
+
})
|
|
31
|
+
await setup.renderOnce()
|
|
32
|
+
const frame = setup.captureCharFrame()
|
|
33
|
+
expect(frame).toContain("Logs")
|
|
34
|
+
expect(frame).toContain("world")
|
|
35
|
+
})
|
|
36
|
+
})
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core"
|
|
2
|
+
import { createRoot } from "@opentui/react"
|
|
3
|
+
import { App } from "./app.js"
|
|
4
|
+
|
|
5
|
+
function readArg(name: string, fallback: string): string {
|
|
6
|
+
const args = Bun.argv.slice(2)
|
|
7
|
+
const index = args.indexOf(name)
|
|
8
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const host = readArg("--host", "localhost")
|
|
12
|
+
const wsPort = Number.parseInt(readArg("--ws_port", "18000"), 10)
|
|
13
|
+
const connectBackend = readArg("--connect", "true") !== "false"
|
|
14
|
+
|
|
15
|
+
const renderer = await createCliRenderer({
|
|
16
|
+
exitOnCtrlC: false
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
createRoot(renderer).render(<App host={host} wsPort={wsPort} connectBackend={connectBackend} />)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { buildInitialFormValues, buildPayload, menu, validateAction } from "./protocol.js"
|
|
3
|
+
|
|
4
|
+
describe("protocol", () => {
|
|
5
|
+
test("buildInitialFormValues copies defaults", () => {
|
|
6
|
+
expect(buildInitialFormValues(0)).toEqual(["", "0"])
|
|
7
|
+
expect(buildInitialFormValues(7)[0]).toContain("ApplicationContext")
|
|
8
|
+
expect(buildInitialFormValues(3)[2]).toBe("asm")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("validateAction enforces signature checks", () => {
|
|
12
|
+
expect(validateAction(0, ["com.demo.Service#run", "1"])).toBe(true)
|
|
13
|
+
expect(validateAction(0, ["com.demo.Service.run", "1"])).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("buildPayload serializes change body request", () => {
|
|
17
|
+
const payload = JSON.parse(buildPayload(3, ["a.B#c", "java.lang.String, int", "javassist", "return 1;"])) as Record<string, unknown>
|
|
18
|
+
expect(payload.type).toBe("CHANGE_BODY")
|
|
19
|
+
expect(payload.className).toBe("a.B")
|
|
20
|
+
expect(payload.method).toBe("c")
|
|
21
|
+
expect(payload.paramTypes).toEqual(["java.lang.String", "int"])
|
|
22
|
+
expect(payload.body).toBe("return 1;")
|
|
23
|
+
expect(payload.mode).toBe(0)
|
|
24
|
+
expect(typeof payload.id).toBe("string")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("buildPayload serializes eval and decompile requests", () => {
|
|
28
|
+
const decompilePayload = JSON.parse(buildPayload(5, ["demo.Service"])) as Record<string, unknown>
|
|
29
|
+
expect(decompilePayload.type).toBe("DECOMPILE")
|
|
30
|
+
expect(decompilePayload.className).toBe("demo.Service")
|
|
31
|
+
|
|
32
|
+
const evalPayload = JSON.parse(buildPayload(6, ["1 + 1"])) as Record<string, unknown>
|
|
33
|
+
expect(evalPayload.type).toBe("EVAL")
|
|
34
|
+
expect(evalPayload.body).toBe("1 + 1")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("menu preserves expected action count", () => {
|
|
38
|
+
expect(menu.map((item) => item.name)).toEqual(["Watch", "OuterWatch", "Trace", "ChangeBody", "ChangeResult", "Decompile", "Eval", "Exec", "Reset"])
|
|
39
|
+
})
|
|
40
|
+
})
|