martty 0.2.11
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/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/dsh-tui.js +68 -0
- package/cordis.patch.yml +30 -0
- package/creator/cordis.patch.yml +6 -0
- package/creator/package.json +10 -0
- package/lib/acp-client-events.js +65 -0
- package/lib/acp-client.js +114 -0
- package/lib/acp-host.js +24 -0
- package/lib/acp-session-config.js +376 -0
- package/lib/acp-session-plan.js +196 -0
- package/lib/acp-session-stats.js +239 -0
- package/lib/agent.js +64 -0
- package/lib/boot.js +119 -0
- package/lib/client-process.js +11 -0
- package/lib/client-run.js +379 -0
- package/lib/cordis-protocol.js +51 -0
- package/lib/creator-overlay.js +77 -0
- package/lib/demo-skin.js +79 -0
- package/lib/ember.js +20 -0
- package/lib/index.js +226 -0
- package/lib/inspect.js +971 -0
- package/lib/jsonrpc-line-transport.js +155 -0
- package/lib/mux.js +281 -0
- package/lib/palettes/default.json +44 -0
- package/lib/palettes/ember.json +44 -0
- package/lib/plan-view.js +92 -0
- package/lib/profile-acp-client.js +11 -0
- package/lib/right-demo.js +55 -0
- package/lib/runner.js +94 -0
- package/lib/spawn-tui.js +179 -0
- package/lib/stats-view.js +90 -0
- package/lib/tui-commands.js +144 -0
- package/lib/tui-overlay.js +252 -0
- package/lib/tui-slots.js +351 -0
- package/lib/tui-theme.js +463 -0
- package/package.json +83 -0
- package/skills/tui-plugin-development/SKILL.md +172 -0
- package/vendor/darwin-arm64/dsh-tui +0 -0
- package/vendor/darwin-x64/dsh-tui +0 -0
- package/vendor/linux-arm64/dsh-tui +0 -0
- package/vendor/linux-x64/dsh-tui +0 -0
- package/vendor/win32-x64/dsh-tui.exe +0 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side TUI launcher.
|
|
3
|
+
*
|
|
4
|
+
* The dsh profile keeps the Base and ACP server on its Host Cordis tree. This
|
|
5
|
+
* plugin starts a separate Node process for the TUI Client Cordis tree and
|
|
6
|
+
* connects the two processes through the Client process's standard ACP
|
|
7
|
+
* stdin/stdout. Separate inherited descriptors carry the user's TTY.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
export const name = 'dsh-tui-runner'
|
|
15
|
+
export const inject = ['loader', 'acpServer', 'cmdlineArgs', 'appExit']
|
|
16
|
+
|
|
17
|
+
const clientEntry = fileURLToPath(new URL('./client-process.js', import.meta.url))
|
|
18
|
+
const requireFromTui = createRequire(import.meta.url)
|
|
19
|
+
|
|
20
|
+
function ownAcpBridge() {
|
|
21
|
+
const specifier = '@openma/deepseek-harness-acp/bridge'
|
|
22
|
+
try {
|
|
23
|
+
return requireFromTui.resolve(specifier)
|
|
24
|
+
} catch {
|
|
25
|
+
// A source-linked package may rely on the active profile's installation.
|
|
26
|
+
return specifier
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function appExitOf(ctx) {
|
|
31
|
+
return ctx.root?.get?.('appExit') ?? ctx.get?.('appExit') ?? ctx.appExit
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {object} ctx */
|
|
35
|
+
export async function apply(ctx) {
|
|
36
|
+
const bridge = await ctx.loader.import(ownAcpBridge())
|
|
37
|
+
if (typeof bridge.nodeAcpStream !== 'function') {
|
|
38
|
+
throw new Error('dsh-tui: ACP bridge does not export nodeAcpStream')
|
|
39
|
+
}
|
|
40
|
+
const child = spawn(
|
|
41
|
+
process.execPath,
|
|
42
|
+
[clientEntry, ...ctx.cmdlineArgs.get()],
|
|
43
|
+
{
|
|
44
|
+
// ACP owns the Client process's stdin/stdout. Its fd 3/4 retain the
|
|
45
|
+
// user's terminal for the Rust painter spawned inside that process.
|
|
46
|
+
stdio: ['pipe', 'pipe', 'inherit', process.stdin, process.stdout],
|
|
47
|
+
env: process.env,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
const agentToClient = child.stdin
|
|
51
|
+
const clientToAgent = child.stdout
|
|
52
|
+
agentToClient?.on?.('error', () => {})
|
|
53
|
+
clientToAgent?.on?.('error', () => {})
|
|
54
|
+
|
|
55
|
+
let connection
|
|
56
|
+
let released = false
|
|
57
|
+
let exitRequested = false
|
|
58
|
+
const requestExit = (code) => {
|
|
59
|
+
if (exitRequested) return
|
|
60
|
+
exitRequested = true
|
|
61
|
+
appExitOf(ctx)?.(code)
|
|
62
|
+
}
|
|
63
|
+
const release = async () => {
|
|
64
|
+
if (released) return
|
|
65
|
+
released = true
|
|
66
|
+
process.off('exit', onProcessExit)
|
|
67
|
+
if (child.exitCode === null) child.kill('SIGTERM')
|
|
68
|
+
await connection?.dispose?.()
|
|
69
|
+
}
|
|
70
|
+
const onProcessExit = () => {
|
|
71
|
+
if (child.exitCode === null) child.kill('SIGTERM')
|
|
72
|
+
}
|
|
73
|
+
child.once('error', (error) => {
|
|
74
|
+
console.error(`dsh-tui: failed to start Client process: ${error.message}`)
|
|
75
|
+
requestExit(1)
|
|
76
|
+
void release()
|
|
77
|
+
})
|
|
78
|
+
child.once('exit', (code, signal) => {
|
|
79
|
+
requestExit(code ?? (signal === 'SIGINT' ? 130 : 1))
|
|
80
|
+
void release()
|
|
81
|
+
})
|
|
82
|
+
process.once('exit', onProcessExit)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
connection = ctx.acpServer.connect(
|
|
86
|
+
bridge.nodeAcpStream(clientToAgent, agentToClient),
|
|
87
|
+
)
|
|
88
|
+
await connection
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await release()
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
ctx.effect(() => release)
|
|
94
|
+
}
|
package/lib/spawn-tui.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn the platform-native `dsh-tui` binary for plugin / demo-skin attach.
|
|
3
|
+
*
|
|
4
|
+
* Unix extra fds: 3 = Node→TUI (Rust reads), 4 = TUI→Node (Rust writes).
|
|
5
|
+
* Windows (and DSH_TUI_FORCE_TCP=1): authenticated loopback TCP.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import { createServer } from 'node:net'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
|
|
17
|
+
/** Select the first existing native painter in development-to-release order. */
|
|
18
|
+
export function selectNativeBinary({ envBin, devBin, packagedBin }) {
|
|
19
|
+
for (const candidate of [envBin, devBin, packagedBin]) {
|
|
20
|
+
if (typeof candidate === 'string' && candidate.length > 0 && fs.existsSync(candidate)) {
|
|
21
|
+
return candidate
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Packaged native binary for this platform, or throw if it was not staged.
|
|
29
|
+
* Honors `DSH_TUI_BIN` when that path exists (source / cargo re-exec).
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function nativeBinary() {
|
|
33
|
+
const envBin = process.env.DSH_TUI_BIN
|
|
34
|
+
const key = `${process.platform}-${process.arch}`
|
|
35
|
+
const exe = process.platform === 'win32' ? 'dsh-tui.exe' : 'dsh-tui'
|
|
36
|
+
const bin = path.join(__dirname, '..', 'vendor', key, exe)
|
|
37
|
+
const devBin = path.join(__dirname, '..', '..', 'target', 'debug', exe)
|
|
38
|
+
const selected = selectNativeBinary({ envBin, devBin, packagedBin: bin })
|
|
39
|
+
if (selected === undefined) {
|
|
40
|
+
let have = []
|
|
41
|
+
try {
|
|
42
|
+
have = fs.readdirSync(path.join(__dirname, '..', 'vendor'))
|
|
43
|
+
} catch {
|
|
44
|
+
// vendor dir missing entirely
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`martty: no native binary for ${key}` +
|
|
48
|
+
` (packaged: ${have.join(', ') || 'none'});` +
|
|
49
|
+
' rebuild the package on this machine with scripts/build-npm.sh',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
return selected
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Spawn the TUI in attach mode. Extra args are appended after `--attach-fds`
|
|
57
|
+
* or `--attach-tcp <addr>`. `--demo-skin` is dropped so Node cannot re-exec
|
|
58
|
+
* itself in a loop.
|
|
59
|
+
* @param {string} bin
|
|
60
|
+
* @param {string[]} [extraArgs]
|
|
61
|
+
* @param {{ stdin?: number | 'inherit', stdout?: number | 'inherit' }} [tty]
|
|
62
|
+
* @returns {Promise<{ child: import('node:child_process').ChildProcess, input: import('node:stream').Writable, output: import('node:stream').Readable, resume?: () => void }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function spawnPluginTui(bin, extraArgs = [], tty = {}) {
|
|
65
|
+
const extra = extraArgs.filter((arg) => arg !== '--demo-skin')
|
|
66
|
+
const ttyIn = tty.stdin ?? 'inherit'
|
|
67
|
+
const ttyOut = tty.stdout ?? 'inherit'
|
|
68
|
+
const useTcp = process.platform === 'win32' || process.env.DSH_TUI_FORCE_TCP === '1'
|
|
69
|
+
if (!useTcp) {
|
|
70
|
+
const child = spawn(bin, ['--attach-fds', ...extra], {
|
|
71
|
+
stdio: [ttyIn, ttyOut, 'inherit', 'pipe', 'pipe'],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Extra-fd writes race the TUI exiting (window closed, or the binary died
|
|
75
|
+
// before a JSON-RPC response flushed). Node treats EPIPE on a Socket with
|
|
76
|
+
// no 'error' listener as an unhandled exception; the child's 'exit' handler
|
|
77
|
+
// is the lifetime signal, so the stream error only needs to be non-fatal.
|
|
78
|
+
// Same posture as @deepseek-ai/dsh-sdk-client toward the runtime stdin.
|
|
79
|
+
child.stdio[3].on('error', () => {})
|
|
80
|
+
child.stdio[4].on('error', () => {})
|
|
81
|
+
return { child, input: child.stdio[4], output: child.stdio[3] }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const token = randomBytes(32).toString('hex')
|
|
85
|
+
const server = createServer()
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
server.once('error', reject)
|
|
88
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
89
|
+
})
|
|
90
|
+
const address = server.address()
|
|
91
|
+
if (address === null || typeof address === 'string') {
|
|
92
|
+
server.close()
|
|
93
|
+
throw new Error('dsh-tui runner failed to allocate a loopback TCP port')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const authenticated = waitForAuthenticatedSocket(server, token)
|
|
97
|
+
const child = spawn(bin, ['--attach-tcp', `127.0.0.1:${address.port}`, ...extra], {
|
|
98
|
+
stdio: [ttyIn, ttyOut, 'inherit'],
|
|
99
|
+
env: { ...process.env, DSH_TUI_ATTACH_TOKEN: token },
|
|
100
|
+
})
|
|
101
|
+
let onChildError
|
|
102
|
+
let onChildExit
|
|
103
|
+
const childFailed = new Promise((_, reject) => {
|
|
104
|
+
onChildError = reject
|
|
105
|
+
onChildExit = (code, signal) => {
|
|
106
|
+
reject(new Error(`dsh-tui exited before connecting (code=${code}, signal=${signal})`))
|
|
107
|
+
}
|
|
108
|
+
child.once('error', onChildError)
|
|
109
|
+
child.once('exit', onChildExit)
|
|
110
|
+
})
|
|
111
|
+
try {
|
|
112
|
+
const socket = await Promise.race([authenticated, childFailed])
|
|
113
|
+
child.off('error', onChildError)
|
|
114
|
+
child.off('exit', onChildExit)
|
|
115
|
+
return { child, input: socket, output: socket, resume: () => socket.resume() }
|
|
116
|
+
} catch (error) {
|
|
117
|
+
server.close()
|
|
118
|
+
try {
|
|
119
|
+
child.kill('SIGTERM')
|
|
120
|
+
} catch {
|
|
121
|
+
// child may already be gone
|
|
122
|
+
}
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function waitForAuthenticatedSocket(server, token) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const timeout = setTimeout(() => {
|
|
130
|
+
cleanup()
|
|
131
|
+
server.close()
|
|
132
|
+
reject(new Error('dsh-tui timed out connecting to the loopback transport'))
|
|
133
|
+
}, 10_000)
|
|
134
|
+
timeout.unref?.()
|
|
135
|
+
|
|
136
|
+
const cleanup = () => {
|
|
137
|
+
clearTimeout(timeout)
|
|
138
|
+
server.off('error', fail)
|
|
139
|
+
server.off('connection', authenticate)
|
|
140
|
+
}
|
|
141
|
+
const fail = (error) => {
|
|
142
|
+
cleanup()
|
|
143
|
+
reject(error)
|
|
144
|
+
}
|
|
145
|
+
const authenticate = (socket) => {
|
|
146
|
+
let buffered = Buffer.alloc(0)
|
|
147
|
+
const onData = (chunk) => {
|
|
148
|
+
buffered = Buffer.concat([buffered, chunk])
|
|
149
|
+
if (buffered.length > 1024) {
|
|
150
|
+
socket.destroy()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const newline = buffered.indexOf(0x0a)
|
|
154
|
+
if (newline < 0) return
|
|
155
|
+
socket.off('data', onData)
|
|
156
|
+
const supplied = buffered.subarray(0, newline).toString('utf8').trim()
|
|
157
|
+
const expectedBytes = Buffer.from(token)
|
|
158
|
+
const suppliedBytes = Buffer.from(supplied)
|
|
159
|
+
const valid = suppliedBytes.length === expectedBytes.length
|
|
160
|
+
&& timingSafeEqual(suppliedBytes, expectedBytes)
|
|
161
|
+
if (!valid) {
|
|
162
|
+
socket.destroy()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
socket.pause()
|
|
166
|
+
const remainder = buffered.subarray(newline + 1)
|
|
167
|
+
if (remainder.length > 0) socket.unshift(remainder)
|
|
168
|
+
cleanup()
|
|
169
|
+
server.close()
|
|
170
|
+
resolve(socket)
|
|
171
|
+
}
|
|
172
|
+
socket.on('data', onData)
|
|
173
|
+
socket.on('error', () => {})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
server.on('error', fail)
|
|
177
|
+
server.on('connection', authenticate)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Built-in Client Plugin: standard ACP usage and timing in the composer dock. */
|
|
2
|
+
|
|
3
|
+
export const name = 'stats-view'
|
|
4
|
+
export const inject = ['acpSessionStats', 'tuiSlots']
|
|
5
|
+
|
|
6
|
+
export function apply(ctx) {
|
|
7
|
+
let current = ctx.acpSessionStats.current()
|
|
8
|
+
let panel
|
|
9
|
+
const stopSlot = ctx.tuiSlots.inject('conversation.composer.dock', () => {
|
|
10
|
+
panel = ctx.tuiSlots.register(
|
|
11
|
+
{ name: 'conversation.composer.dock', id: 'stats' },
|
|
12
|
+
nodesOf(current),
|
|
13
|
+
)
|
|
14
|
+
return () => panel.dispose()
|
|
15
|
+
})
|
|
16
|
+
const stopStats = ctx.acpSessionStats.subscribe((snapshot) => {
|
|
17
|
+
current = snapshot
|
|
18
|
+
panel?.update(nodesOf(current))
|
|
19
|
+
})
|
|
20
|
+
return () => {
|
|
21
|
+
stopStats?.()
|
|
22
|
+
stopSlot?.()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodesOf(snapshot) {
|
|
27
|
+
const usage = snapshot?.usage ?? {}
|
|
28
|
+
const stats = snapshot?.stats ?? {}
|
|
29
|
+
const nodes = []
|
|
30
|
+
if ((usage.input ?? 0) > 0 || (usage.output ?? 0) > 0) {
|
|
31
|
+
nodes.push(node('tokens', `Input ${formatTokens(usage.input)} tok · Output ${formatTokens(usage.output)} tok`))
|
|
32
|
+
}
|
|
33
|
+
if ((stats.turns ?? 0) > 0 || (stats.steps ?? 0) > 0) {
|
|
34
|
+
nodes.push(node(
|
|
35
|
+
'counts',
|
|
36
|
+
`${stats.turns ?? 0} ${plural(stats.turns ?? 0, 'turn')} · ${stats.steps ?? 0} ${plural(stats.steps ?? 0, 'step')}`,
|
|
37
|
+
))
|
|
38
|
+
}
|
|
39
|
+
if ((usage.input ?? 0) > 0) {
|
|
40
|
+
const cacheRead = usage.cacheRead ?? usage.cached ?? 0
|
|
41
|
+
const cacheWrite = usage.cacheWrite ?? 0
|
|
42
|
+
const eligible = usage.input + cacheRead + cacheWrite
|
|
43
|
+
const rate = eligible > 0 ? Math.round(cacheRead / eligible * 100) : 0
|
|
44
|
+
nodes.push(node('cache', `Cache hit ${rate}%`))
|
|
45
|
+
}
|
|
46
|
+
if ((stats.llmMillis ?? 0) > 0 || (stats.toolMillis ?? 0) > 0) {
|
|
47
|
+
nodes.push(node(
|
|
48
|
+
'time',
|
|
49
|
+
`LLM ${formatDuration(stats.llmMillis ?? 0)} · Tool call ${formatDuration(stats.toolMillis ?? 0)}`,
|
|
50
|
+
))
|
|
51
|
+
}
|
|
52
|
+
if ((stats.ttftCount ?? 0) > 0 || ((usage.output ?? 0) > 0 && (stats.llmMillis ?? 0) > 0)) {
|
|
53
|
+
const parts = []
|
|
54
|
+
if ((stats.ttftCount ?? 0) > 0) {
|
|
55
|
+
parts.push(`TTFT avg ${formatDuration(stats.ttftTotalMillis / stats.ttftCount)}`)
|
|
56
|
+
}
|
|
57
|
+
if ((usage.output ?? 0) > 0 && (stats.llmMillis ?? 0) > 0) {
|
|
58
|
+
parts.push(`${formatRate(usage.output / (stats.llmMillis / 1000))} tok/s`)
|
|
59
|
+
}
|
|
60
|
+
nodes.push(node('speed', parts.join(' · ')))
|
|
61
|
+
}
|
|
62
|
+
return nodes
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function node(id, title) {
|
|
66
|
+
return { id, kind: 'generic', title, body: '' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function plural(value, singular) { return value === 1 ? singular : `${singular}s` }
|
|
70
|
+
|
|
71
|
+
function formatTokens(value = 0) {
|
|
72
|
+
if (value < 1000) return String(value)
|
|
73
|
+
const unit = value < 1_000_000 ? 1000 : 1_000_000
|
|
74
|
+
const suffix = unit === 1000 ? 'K' : 'M'
|
|
75
|
+
const scaled = value / unit
|
|
76
|
+
const rounded = scaled >= 100 ? Math.round(scaled) : Math.round(scaled * 10) / 10
|
|
77
|
+
return `${rounded}${suffix}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatDuration(ms) {
|
|
81
|
+
if (ms < 60_000) return `${Math.round(ms / 100) / 10}s`
|
|
82
|
+
const seconds = Math.round(ms / 1000)
|
|
83
|
+
return `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatRate(value) {
|
|
87
|
+
return String(Math.round(value * 10) / 10)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { formatDuration, formatTokens, nodesOf }
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Lifecycle-owned local slash commands for TUI Client Plugins. */
|
|
2
|
+
|
|
3
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
4
|
+
import { CORDIS_METHODS } from './cordis-protocol.js'
|
|
5
|
+
|
|
6
|
+
export const name = 'tui-commands'
|
|
7
|
+
export const inject = []
|
|
8
|
+
|
|
9
|
+
const PROTOCOL = 0
|
|
10
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
11
|
+
|
|
12
|
+
class TuiCommandsService extends Service {
|
|
13
|
+
constructor(ctx, core) {
|
|
14
|
+
super(ctx, 'tuiCommands')
|
|
15
|
+
this.core = core
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
register(options, handler) {
|
|
19
|
+
return this.core.register(this.ctx, options, handler)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
dispatch(params) {
|
|
23
|
+
return this.core.dispatch(params)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
list() {
|
|
27
|
+
return this.core.list()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
bindNotify(notify) {
|
|
31
|
+
return this.core.bindNotify(notify)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateCommand(options) {
|
|
36
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
37
|
+
throw new Error('tuiCommands.register: options must be an object')
|
|
38
|
+
}
|
|
39
|
+
if (typeof options.name !== 'string' || !NAME.test(options.name)) {
|
|
40
|
+
throw new Error('tuiCommands.register: name must be a lowercase slash identifier without /')
|
|
41
|
+
}
|
|
42
|
+
if (typeof options.description !== 'string' || options.description.length === 0) {
|
|
43
|
+
throw new Error('tuiCommands.register: description must be a non-empty string')
|
|
44
|
+
}
|
|
45
|
+
const extra = Object.keys(options).filter((key) => key !== 'name' && key !== 'description')
|
|
46
|
+
if (extra.length > 0) {
|
|
47
|
+
throw new Error(`tuiCommands.register: unknown option field(s) ${extra.join(', ')}`)
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name: options.name,
|
|
51
|
+
description: options.description,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} ctx
|
|
57
|
+
* @param {{ notify?: (method: string, params: object) => void }} [options]
|
|
58
|
+
*/
|
|
59
|
+
export function installTuiCommands(ctx, options = {}) {
|
|
60
|
+
const entries = new Map()
|
|
61
|
+
let send = typeof options.notify === 'function' ? options.notify : undefined
|
|
62
|
+
|
|
63
|
+
function publish() {
|
|
64
|
+
if (typeof send !== 'function') return
|
|
65
|
+
send(CORDIS_METHODS.commandsUpdate, {
|
|
66
|
+
protocol: PROTOCOL,
|
|
67
|
+
commands: [...entries.values()].map((entry) => ({ ...entry.command })),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bindNotify(notify) {
|
|
72
|
+
if (typeof notify !== 'function') throw new Error('tuiCommands.bindNotify: notify must be a function')
|
|
73
|
+
send = notify
|
|
74
|
+
publish()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function register(effectCtx, options, handler) {
|
|
78
|
+
const command = validateCommand(options)
|
|
79
|
+
if (typeof handler !== 'function') {
|
|
80
|
+
throw new Error('tuiCommands.register: handler must be a function')
|
|
81
|
+
}
|
|
82
|
+
if (entries.has(command.name)) {
|
|
83
|
+
throw new Error(`tuiCommands.register: command "${command.name}" is already registered`)
|
|
84
|
+
}
|
|
85
|
+
const entry = { command, handler, active: false }
|
|
86
|
+
const setup = () => {
|
|
87
|
+
if (entries.has(command.name)) {
|
|
88
|
+
throw new Error(`tuiCommands.register: command "${command.name}" is already registered`)
|
|
89
|
+
}
|
|
90
|
+
entry.active = true
|
|
91
|
+
entries.set(command.name, entry)
|
|
92
|
+
publish()
|
|
93
|
+
return () => {
|
|
94
|
+
if (!entry.active) return
|
|
95
|
+
entry.active = false
|
|
96
|
+
if (entries.get(command.name) === entry) entries.delete(command.name)
|
|
97
|
+
publish()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const release = typeof effectCtx.effect === 'function'
|
|
101
|
+
? effectCtx.effect(setup, `tuiCommands.register(${JSON.stringify(command.name)})`)
|
|
102
|
+
: setup()
|
|
103
|
+
let disposed = false
|
|
104
|
+
return () => {
|
|
105
|
+
if (disposed) return
|
|
106
|
+
disposed = true
|
|
107
|
+
return release?.()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function dispatch(params) {
|
|
112
|
+
if (params === null || typeof params !== 'object' || params.protocol !== PROTOCOL) {
|
|
113
|
+
throw new Error('tuiCommands.dispatch: unsupported command payload')
|
|
114
|
+
}
|
|
115
|
+
const entry = entries.get(params.name)
|
|
116
|
+
if (entry === undefined || !entry.active) {
|
|
117
|
+
throw new Error(`tuiCommands.dispatch: command "${String(params.name)}" is not registered`)
|
|
118
|
+
}
|
|
119
|
+
const args = typeof params.args === 'string' ? params.args : ''
|
|
120
|
+
return entry.handler(args, { name: entry.command.name })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function list() {
|
|
124
|
+
return [...entries.values()].map((entry) => ({ ...entry.command }))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const core = { register, dispatch, list, bindNotify }
|
|
128
|
+
const service = typeof ctx.provide === 'function'
|
|
129
|
+
? new TuiCommandsService(ctx, core)
|
|
130
|
+
: {
|
|
131
|
+
register(options, handler) {
|
|
132
|
+
return register(ctx, options, handler)
|
|
133
|
+
},
|
|
134
|
+
dispatch,
|
|
135
|
+
list,
|
|
136
|
+
bindNotify,
|
|
137
|
+
}
|
|
138
|
+
if (typeof ctx.provide !== 'function') ctx.tuiCommands = service
|
|
139
|
+
return service
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function apply(ctx) {
|
|
143
|
+
installTuiCommands(ctx)
|
|
144
|
+
}
|