@fonlan/dsh-quick-commands 0.1.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/LICENSE +21 -0
- package/README.en.md +149 -0
- package/README.md +108 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1036 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +904 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/server/remote.d.ts +117 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +114 -0
- package/lib/types/server/settings.d.ts +24 -0
- package/lib/types/shared/contract.d.ts +87 -0
- package/package.json +108 -0
- package/src/client/api.ts +76 -0
- package/src/client/header.tsx +534 -0
- package/src/client/index.tsx +50 -0
- package/src/client/locales.ts +106 -0
- package/src/client/quick-commands.css +580 -0
- package/src/client/run-state.ts +125 -0
- package/src/client/settings-card.tsx +209 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +61 -0
- package/src/server/remote.ts +304 -0
- package/src/server/rpc.ts +286 -0
- package/src/server/runner.ts +471 -0
- package/src/server/settings.ts +106 -0
- package/src/shared/contract.ts +92 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-header quick-command surface: the ▶ play icon button (left of the
|
|
3
|
+
* Session log pill), the command menu popover (current workspace's commands),
|
|
4
|
+
* and the live output popup that streams stdout/stderr at ~200ms cadence.
|
|
5
|
+
*
|
|
6
|
+
* Props are the standard session/global kit of `conversation.session.header.utilities`
|
|
7
|
+
* plus this plugin's injected hooks (anchor preference + resolver).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
IconPlayOutline16,
|
|
12
|
+
IconCloseOutline16,
|
|
13
|
+
IconStopFill16,
|
|
14
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
15
|
+
import { quickApi } from './api'
|
|
16
|
+
import { useRunPopover, openMenu, closeMenu, openRun, closeRun } from './run-state'
|
|
17
|
+
import type { QuickPopupSize } from '../shared/contract'
|
|
18
|
+
|
|
19
|
+
/** Workspace row shape the standard useWorkspaces hook exposes. */
|
|
20
|
+
interface WorkspaceRow {
|
|
21
|
+
workspaceId: string
|
|
22
|
+
path: string
|
|
23
|
+
title: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Session list state subset we read (cwd + id). */
|
|
27
|
+
interface SessionRow {
|
|
28
|
+
id: string
|
|
29
|
+
cwd?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface HeaderProps {
|
|
33
|
+
sessionId: string
|
|
34
|
+
t: (key: string) => string
|
|
35
|
+
useSessions?: (selector: (s: { byId: Record<string, SessionRow>; current?: string }) => unknown) => unknown
|
|
36
|
+
useWorkspaces?: (selector: (s: { items: WorkspaceRow[] }) => unknown) => unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Longest-path workspace match for a cwd (subdirectory allowed). */
|
|
40
|
+
function matchWorkspace(cwd: string | undefined, workspaces: WorkspaceRow[]): WorkspaceRow | undefined {
|
|
41
|
+
if (cwd === undefined) return undefined
|
|
42
|
+
let best: WorkspaceRow | undefined
|
|
43
|
+
for (const ws of workspaces) {
|
|
44
|
+
const norm = ws.path.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
45
|
+
const nc = cwd.replace(/\\/g, '/')
|
|
46
|
+
if (nc === norm || nc.startsWith(norm + '/')) {
|
|
47
|
+
if (best === undefined || norm.length > best.path.length) best = ws
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return best
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve the current session's workspace from the standard kits. */
|
|
54
|
+
function useCurrentWorkspace(
|
|
55
|
+
sessionId: string,
|
|
56
|
+
useSessions?: HeaderProps['useSessions'],
|
|
57
|
+
useWorkspaces?: HeaderProps['useWorkspaces'],
|
|
58
|
+
): { workspace: WorkspaceRow | undefined; cwd: string | undefined } {
|
|
59
|
+
const row = useSessions?.((s) => s.byId[sessionId]) as SessionRow | undefined
|
|
60
|
+
const workspaces = useWorkspaces?.((s) => s.items) as WorkspaceRow[] | undefined
|
|
61
|
+
return { workspace: matchWorkspace(row?.cwd, workspaces ?? []), cwd: row?.cwd }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Header icon button + command menu + live output popup. */
|
|
65
|
+
export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
|
|
66
|
+
const { sessionId, t } = props
|
|
67
|
+
const { workspace, cwd } = useCurrentWorkspace(sessionId, props.useSessions, props.useWorkspaces)
|
|
68
|
+
const popover = useRunPopover()
|
|
69
|
+
const [commands, setCommands] = useState<Array<{ name: string; command: string }>>([])
|
|
70
|
+
const [menuLoadError, setMenuLoadError] = useState<string | null>(null)
|
|
71
|
+
const [menuRemoteHost, setMenuRemoteHost] = useState<string | null>(null)
|
|
72
|
+
const [busy, setBusy] = useState(false)
|
|
73
|
+
const [anchor, setAnchor] = useState<'corner' | 'button'>('corner')
|
|
74
|
+
const [popupSize, setPopupSize] = useState<QuickPopupSize | undefined>(undefined)
|
|
75
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
76
|
+
|
|
77
|
+
// Click-outside dismissal: while the menu is open, a pointerdown on the
|
|
78
|
+
// document that lands OUTSIDE the wrap (button / menu / run popup all live
|
|
79
|
+
// inside the wrap) collapses the menu. pointerdown (not click) covers mouse,
|
|
80
|
+
// touch and pen, and fires before the button's own click toggle.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!popover.menuOpen) return
|
|
83
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
84
|
+
const wrap = wrapRef.current
|
|
85
|
+
if (wrap === null) return
|
|
86
|
+
if (e.target instanceof Node && wrap.contains(e.target)) return
|
|
87
|
+
closeMenu()
|
|
88
|
+
}
|
|
89
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
90
|
+
return () => { document.removeEventListener('pointerdown', onPointerDown) }
|
|
91
|
+
}, [popover.menuOpen])
|
|
92
|
+
|
|
93
|
+
// Load the workspace's command list + anchor when the menu opens.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!popover.menuOpen) return
|
|
96
|
+
let cancelled = false
|
|
97
|
+
void (async () => {
|
|
98
|
+
try {
|
|
99
|
+
const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
|
|
100
|
+
if (cancelled) return
|
|
101
|
+
const own = entries.find((e) => e.workspaceId === workspace?.workspaceId)
|
|
102
|
+
setCommands(own === undefined ? [] : own.commands)
|
|
103
|
+
setMenuRemoteHost(own?.remoteHost ?? null)
|
|
104
|
+
setAnchor(settings.popupAnchor)
|
|
105
|
+
setPopupSize(settings.popupSize)
|
|
106
|
+
setMenuLoadError(null)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (cancelled) return
|
|
109
|
+
setCommands([])
|
|
110
|
+
setMenuLoadError(e instanceof Error ? e.message : String(e))
|
|
111
|
+
}
|
|
112
|
+
})()
|
|
113
|
+
return () => { cancelled = true }
|
|
114
|
+
}, [popover.menuOpen, workspace?.workspaceId])
|
|
115
|
+
|
|
116
|
+
const startRun = async (commandName: string): Promise<void> => {
|
|
117
|
+
setBusy(true)
|
|
118
|
+
closeMenu()
|
|
119
|
+
try {
|
|
120
|
+
const run = await quickApi.runStart(workspace!.workspaceId, commandName, cwd ?? workspace!.path)
|
|
121
|
+
openRun(run.runId, run.workspaceId, run.commandName, run.remoteHost)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
124
|
+
if (message.includes('run-busy') || message.includes('already running')) {
|
|
125
|
+
setMenuLoadError(t('runBusy'))
|
|
126
|
+
} else {
|
|
127
|
+
setMenuLoadError(t('runStartFailed') + ': ' + message)
|
|
128
|
+
}
|
|
129
|
+
openMenu()
|
|
130
|
+
} finally {
|
|
131
|
+
setBusy(false)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div ref={wrapRef} className="qc-head-wrap">
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="qc-head-btn"
|
|
140
|
+
title={workspace === undefined ? t('noWorkspace') : t('buttonTooltip')}
|
|
141
|
+
aria-label={t('buttonTitle')}
|
|
142
|
+
disabled={workspace === undefined}
|
|
143
|
+
onClick={() => { popover.menuOpen ? closeMenu() : openMenu() }}
|
|
144
|
+
>
|
|
145
|
+
<IconPlayOutline16 size={16} />
|
|
146
|
+
</button>
|
|
147
|
+
|
|
148
|
+
{popover.menuOpen && workspace !== undefined && (
|
|
149
|
+
<div className="qc-menu" role="menu">
|
|
150
|
+
<div className="qc-menu-head">
|
|
151
|
+
<span className="qc-menu-title">{t('menuTitle')}</span>
|
|
152
|
+
{menuRemoteHost !== null && <span className="qc-menu-remote" title={`SSH · ${menuRemoteHost}`}>SSH</span>}
|
|
153
|
+
<span className="qc-menu-ws">{workspace.title}</span>
|
|
154
|
+
<button type="button" className="qc-menu-close" aria-label={t('runClose')} onClick={closeMenu}>
|
|
155
|
+
<IconCloseOutline16 size={12} />
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
{commands.length === 0 && (
|
|
159
|
+
<p className="qc-menu-empty">{menuLoadError ?? t('noCommands')}</p>
|
|
160
|
+
)}
|
|
161
|
+
{commands.filter((c) => c.name.trim() !== '').map((command) => (
|
|
162
|
+
<button
|
|
163
|
+
key={command.name}
|
|
164
|
+
type="button"
|
|
165
|
+
role="menuitem"
|
|
166
|
+
className="qc-menu-item"
|
|
167
|
+
disabled={busy}
|
|
168
|
+
onClick={() => void startRun(command.name)}
|
|
169
|
+
>
|
|
170
|
+
<span className="qc-menu-item-name">{command.name}</span>
|
|
171
|
+
<span className="qc-menu-item-cmd">{command.command}</span>
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{popover.runId !== null && (
|
|
178
|
+
<RunPopup
|
|
179
|
+
runId={popover.runId}
|
|
180
|
+
workspaceId={popover.workspaceId ?? ''}
|
|
181
|
+
commandName={popover.commandName ?? ''}
|
|
182
|
+
remoteHost={popover.remoteHost ?? undefined}
|
|
183
|
+
t={t}
|
|
184
|
+
anchor={anchor}
|
|
185
|
+
popupSize={popupSize}
|
|
186
|
+
sessionId={sessionId}
|
|
187
|
+
onClose={closeRun}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── popup drag-resize ───────────────────────────────────────────────────────
|
|
195
|
+
// The popup pins its RIGHT edge (both anchors), plus its BOTTOM edge (corner
|
|
196
|
+
// anchor) or TOP edge (button anchor, pinned below the button). So the MOVABLE
|
|
197
|
+
// borders are the LEFT border (width) and the TOP border (corner anchor) /
|
|
198
|
+
// BOTTOM border (button anchor) (height); the free corner (top-left /
|
|
199
|
+
// bottom-left) carries a diagonal grip for simultaneous two-axis resizing.
|
|
200
|
+
// Every handle follows the cursor edge-for-edge — pulling the left border LEFT
|
|
201
|
+
// widens, pulling the top border UP grows taller — nothing feels inverted.
|
|
202
|
+
// The size is clamped to viewport bounds and persisted to settings.
|
|
203
|
+
|
|
204
|
+
const POPUP_MIN_W = 320
|
|
205
|
+
const POPUP_MIN_H = 200
|
|
206
|
+
/** Total horizontal margin kept around the popup (both anchors). */
|
|
207
|
+
const POPUP_VIEWPORT_MARGIN = 40
|
|
208
|
+
/** Vertical space reserved for header/footer chrome (both anchors). */
|
|
209
|
+
const POPUP_VIEWPORT_RESERVED = 120
|
|
210
|
+
|
|
211
|
+
function popupDefaultSize(anchor: 'corner' | 'button'): QuickPopupSize {
|
|
212
|
+
return anchor === 'corner' ? { width: 520, height: 340 } : { width: 440, height: 300 }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function popupClamp(size: QuickPopupSize): QuickPopupSize {
|
|
216
|
+
const maxW = Math.max(POPUP_MIN_W, window.innerWidth - POPUP_VIEWPORT_MARGIN)
|
|
217
|
+
const maxH = Math.max(POPUP_MIN_H, window.innerHeight - POPUP_VIEWPORT_RESERVED)
|
|
218
|
+
return {
|
|
219
|
+
width: Math.min(Math.max(POPUP_MIN_W, Math.round(size.width)), maxW),
|
|
220
|
+
height: Math.min(Math.max(POPUP_MIN_H, Math.round(size.height)), maxH),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Initial popup size: the persisted one when it holds sane numbers, else the
|
|
226
|
+
* per-anchor default. Defensive because schemastery resolves a MISSING object
|
|
227
|
+
* field to `{}` (object schemas carry an implicit `default: {}`), so a
|
|
228
|
+
* never-resized popup can arrive as `popupSize: {}` from settings.get().
|
|
229
|
+
*/
|
|
230
|
+
function popupSizeOrDefault(size: QuickPopupSize | undefined, anchor: 'corner' | 'button'): QuickPopupSize {
|
|
231
|
+
if (
|
|
232
|
+
size === undefined
|
|
233
|
+
|| !Number.isFinite(size.width)
|
|
234
|
+
|| !Number.isFinite(size.height)
|
|
235
|
+
|| size.width <= 0
|
|
236
|
+
|| size.height <= 0
|
|
237
|
+
) {
|
|
238
|
+
return popupDefaultSize(anchor)
|
|
239
|
+
}
|
|
240
|
+
return popupClamp(size)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The streaming output popup. Polls stdout/stderr deltas every ~200ms. */
|
|
244
|
+
function RunPopup(props: {
|
|
245
|
+
runId: string
|
|
246
|
+
workspaceId: string
|
|
247
|
+
commandName: string
|
|
248
|
+
remoteHost?: string
|
|
249
|
+
t: (key: string) => string
|
|
250
|
+
anchor: 'corner' | 'button'
|
|
251
|
+
popupSize?: QuickPopupSize
|
|
252
|
+
sessionId: string
|
|
253
|
+
onClose: () => void
|
|
254
|
+
}): JSX.Element {
|
|
255
|
+
const { runId, t, anchor, remoteHost, onClose } = props
|
|
256
|
+
const [size, setSize] = useState<QuickPopupSize>(() => popupSizeOrDefault(props.popupSize, anchor))
|
|
257
|
+
// Mirrors `size` for drag handlers: pointer events can arrive faster than
|
|
258
|
+
// React commits, so pointerup must read the latest value, not the render's.
|
|
259
|
+
const sizeRef = useRef<QuickPopupSize>(size)
|
|
260
|
+
const sizeDragRef = useRef<{
|
|
261
|
+
pointerId: number
|
|
262
|
+
startX: number
|
|
263
|
+
startY: number
|
|
264
|
+
startW: number
|
|
265
|
+
startH: number
|
|
266
|
+
/** Which handle started the drag (controls which axes move). */
|
|
267
|
+
mode: 'left' | 'height' | 'corner'
|
|
268
|
+
} | null>(null)
|
|
269
|
+
const [state, setState] = useState(() => ({
|
|
270
|
+
status: 'running' as 'running' | 'exited',
|
|
271
|
+
exitCode: null as number | null,
|
|
272
|
+
signal: null as string | null,
|
|
273
|
+
stdoutEnd: 0,
|
|
274
|
+
stderrEnd: 0,
|
|
275
|
+
stdout: '',
|
|
276
|
+
stderr: '',
|
|
277
|
+
stdoutLossy: false,
|
|
278
|
+
stderrLossy: false,
|
|
279
|
+
}))
|
|
280
|
+
const [view, setView] = useState<'stdout' | 'stderr'>('stdout')
|
|
281
|
+
const [autoFollow, setAutoFollow] = useState(true)
|
|
282
|
+
const [killConfirm, setKillConfirm] = useState(false)
|
|
283
|
+
const [pollError, setPollError] = useState<string | null>(null)
|
|
284
|
+
const [closing, setClosing] = useState(false)
|
|
285
|
+
const outputRef = useRef<HTMLDivElement | null>(null)
|
|
286
|
+
const autoFollowRef = useRef(true)
|
|
287
|
+
|
|
288
|
+
// Auto-follow: scroll the output pane to the bottom on new output while the
|
|
289
|
+
// user has not scrolled up (autoFollow mirror keeps the effect cheap).
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
autoFollowRef.current = autoFollow
|
|
292
|
+
}, [autoFollow])
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!autoFollowRef.current) return
|
|
295
|
+
const el = outputRef.current
|
|
296
|
+
if (el === null) return
|
|
297
|
+
el.scrollTop = el.scrollHeight
|
|
298
|
+
}, [state.stdout, state.stderr, view])
|
|
299
|
+
|
|
300
|
+
// Poll deltas; stop polling at exit (the run stays readable in memory).
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
let cancelled = false
|
|
303
|
+
let outEnd = 0
|
|
304
|
+
let errEnd = 0
|
|
305
|
+
const tick = async (): Promise<void> => {
|
|
306
|
+
try {
|
|
307
|
+
const poll = await quickApi.runPoll(runId, outEnd, errEnd)
|
|
308
|
+
if (cancelled) return
|
|
309
|
+
outEnd = poll.stdoutEnd
|
|
310
|
+
errEnd = poll.stderrEnd
|
|
311
|
+
setState((s) => ({
|
|
312
|
+
status: poll.status,
|
|
313
|
+
exitCode: poll.exitCode,
|
|
314
|
+
signal: poll.signal,
|
|
315
|
+
stdoutEnd: poll.stdoutEnd,
|
|
316
|
+
stderrEnd: poll.stderrEnd,
|
|
317
|
+
stdout: poll.stdoutLossy ? poll.stdoutDelta : s.stdout + poll.stdoutDelta,
|
|
318
|
+
stderr: poll.stderrLossy ? poll.stderrDelta : s.stderr + poll.stderrDelta,
|
|
319
|
+
stdoutLossy: poll.stdoutLossy,
|
|
320
|
+
stderrLossy: poll.stderrLossy,
|
|
321
|
+
}))
|
|
322
|
+
setPollError(null)
|
|
323
|
+
if (poll.status === 'exited') return
|
|
324
|
+
timer = window.setTimeout(() => void tick(), 200)
|
|
325
|
+
} catch (e) {
|
|
326
|
+
if (cancelled) return
|
|
327
|
+
setPollError(e instanceof Error ? e.message : String(e))
|
|
328
|
+
timer = window.setTimeout(() => void tick(), 500)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
let timer: number
|
|
332
|
+
void tick()
|
|
333
|
+
return () => { cancelled = true; window.clearTimeout(timer) }
|
|
334
|
+
}, [runId])
|
|
335
|
+
|
|
336
|
+
const kill = async (): Promise<void> => {
|
|
337
|
+
setClosing(true)
|
|
338
|
+
try {
|
|
339
|
+
await quickApi.runKill(runId)
|
|
340
|
+
} finally {
|
|
341
|
+
onClose()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const close = (): void => {
|
|
346
|
+
// Already exited: there is nothing left to terminate, close directly
|
|
347
|
+
// instead of asking "terminate this command?".
|
|
348
|
+
if (state.status === 'exited') {
|
|
349
|
+
onClose()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
setKillConfirm(true)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const confirmClose = (): void => {
|
|
356
|
+
void kill()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── drag-resize: pointer capture on the handles keeps moving/cancelling
|
|
360
|
+
// events flowing even when the cursor leaves the handle or the window.
|
|
361
|
+
// Which border is free to move depends on the anchor: the corner anchor
|
|
362
|
+
// pins bottom+right (top border moves for height), the button anchor pins
|
|
363
|
+
// top+right (bottom border moves for height).
|
|
364
|
+
const heightFree: 'top' | 'bottom' = anchor === 'corner' ? 'top' : 'bottom'
|
|
365
|
+
|
|
366
|
+
const onResizePointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
367
|
+
e.preventDefault() // keep the handle from starting a text-selection gesture
|
|
368
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
369
|
+
sizeDragRef.current = {
|
|
370
|
+
pointerId: e.pointerId,
|
|
371
|
+
startX: e.clientX,
|
|
372
|
+
startY: e.clientY,
|
|
373
|
+
startW: sizeRef.current.width,
|
|
374
|
+
startH: sizeRef.current.height,
|
|
375
|
+
mode: e.currentTarget.dataset.resize as 'left' | 'height' | 'corner',
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const onResizePointerMove = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
380
|
+
const drag = sizeDragRef.current
|
|
381
|
+
if (drag === null || drag.pointerId !== e.pointerId) return
|
|
382
|
+
const dx = e.clientX - drag.startX
|
|
383
|
+
const dy = e.clientY - drag.startY
|
|
384
|
+
const controlsW = drag.mode === 'left' || drag.mode === 'corner'
|
|
385
|
+
const controlsH = drag.mode === 'height' || drag.mode === 'corner'
|
|
386
|
+
const next = popupClamp({
|
|
387
|
+
// Left border follows the cursor: dragging LEFT widens, RIGHT narrows.
|
|
388
|
+
width: controlsW ? drag.startW - dx : drag.startW,
|
|
389
|
+
// Top-free (corner): dragging UP grows. Bottom-free (button): DOWN grows.
|
|
390
|
+
height: controlsH ? drag.startH + (heightFree === 'top' ? -dy : dy) : drag.startH,
|
|
391
|
+
})
|
|
392
|
+
sizeRef.current = next
|
|
393
|
+
setSize(next)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const onResizePointerUp = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
397
|
+
const drag = sizeDragRef.current
|
|
398
|
+
if (drag === null || drag.pointerId !== e.pointerId) return
|
|
399
|
+
sizeDragRef.current = null
|
|
400
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
401
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
402
|
+
}
|
|
403
|
+
// Persist best-effort: a failed save just means the next popup reopens at
|
|
404
|
+
// the default size (or the last successfully saved one).
|
|
405
|
+
void quickApi.settingsSetPopupSize(sizeRef.current).catch(() => undefined)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Pointer-handler trio for one resize handle (mode = which axes it drives). */
|
|
409
|
+
const resizeProps = (mode: 'left' | 'height' | 'corner') => ({
|
|
410
|
+
'data-resize': mode,
|
|
411
|
+
onPointerDown: onResizePointerDown,
|
|
412
|
+
onPointerMove: onResizePointerMove,
|
|
413
|
+
onPointerUp: onResizePointerUp,
|
|
414
|
+
onPointerCancel: onResizePointerUp,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
const active = state.status === 'running'
|
|
418
|
+
const title = active ? `${props.commandName} · ${t('runStateRunning')}` : `${props.commandName} · ${t('runStateExited')} (${state.exitCode ?? '?'})`
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div className={`qc-popup qc-popup-${anchor}`} role="dialog" aria-label={title} style={{ width: size.width, height: size.height }}>
|
|
422
|
+
<header className="qc-popup-head">
|
|
423
|
+
<span className="qc-popup-title" title={title}>{title}</span>
|
|
424
|
+
{remoteHost !== undefined && <span className="qc-popup-remote" title={`SSH · ${remoteHost}`}>SSH</span>}
|
|
425
|
+
<span className="qc-popup-cmd">{props.commandName}</span>
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
className="qc-popup-icon qc-popup-kill"
|
|
429
|
+
disabled={!active || closing}
|
|
430
|
+
title={t('runKill')}
|
|
431
|
+
aria-label={t('runKill')}
|
|
432
|
+
onClick={() => void kill()}
|
|
433
|
+
>
|
|
434
|
+
<IconStopFill16 size={14} />
|
|
435
|
+
</button>
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
className="qc-popup-icon qc-popup-close"
|
|
439
|
+
title={t('runClose')}
|
|
440
|
+
aria-label={t('runClose')}
|
|
441
|
+
onClick={close}
|
|
442
|
+
>
|
|
443
|
+
<IconCloseOutline16 size={14} />
|
|
444
|
+
</button>
|
|
445
|
+
</header>
|
|
446
|
+
|
|
447
|
+
{killConfirm && (
|
|
448
|
+
<div className="qc-popup-confirm">
|
|
449
|
+
<span>{t('runKillConfirm')}</span>
|
|
450
|
+
<button type="button" className="qc-popup-btn" onClick={confirmClose}>{t('runKill')}</button>
|
|
451
|
+
<button type="button" className="qc-popup-btn qc-popup-btn-plain" onClick={() => setKillConfirm(false)}>
|
|
452
|
+
{t('runClose')}
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
<div className="qc-popup-tabs">
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
className={'qc-popup-tab' + (view === 'stdout' ? ' qc-popup-tab-active' : '')}
|
|
461
|
+
onClick={() => setView('stdout')}
|
|
462
|
+
>
|
|
463
|
+
{t('runStdout')}
|
|
464
|
+
</button>
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
className={'qc-popup-tab' + (view === 'stderr' ? ' qc-popup-tab-active' : '')}
|
|
468
|
+
onClick={() => setView('stderr')}
|
|
469
|
+
>
|
|
470
|
+
{t('runStderr')}
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div
|
|
475
|
+
ref={outputRef}
|
|
476
|
+
className="qc-popup-output"
|
|
477
|
+
onScroll={(e) => {
|
|
478
|
+
const el = e.currentTarget
|
|
479
|
+
const atEnd = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 24
|
|
480
|
+
setAutoFollow(atEnd)
|
|
481
|
+
}}
|
|
482
|
+
>
|
|
483
|
+
{pollError !== null && <p className="qc-popup-error">{t('runPollFailed')}: {pollError}</p>}
|
|
484
|
+
{view === 'stdout' ? (
|
|
485
|
+
state.stdout === '' && !active ? <p className="qc-popup-empty">{t('runEmpty')}</p>
|
|
486
|
+
: <pre className="qc-popup-pre">{state.stdout}</pre>
|
|
487
|
+
) : (
|
|
488
|
+
state.stderr === '' && !active ? <p className="qc-popup-empty">{t('runEmpty')}</p>
|
|
489
|
+
: <pre className="qc-popup-pre">{state.stderr}</pre>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<footer className="qc-popup-foot">
|
|
494
|
+
<span className="qc-popup-state">
|
|
495
|
+
{active ? t('runStateRunning') : `${t('runStateExited')} · ${t('runExitCode')} ${state.exitCode ?? '-'}${state.signal !== null ? ` · ${state.signal}` : ''}`}
|
|
496
|
+
</span>
|
|
497
|
+
</footer>
|
|
498
|
+
|
|
499
|
+
{/* Resize handles: left border = width; top (corner anchor) / bottom
|
|
500
|
+
(button anchor) border = height; free corner = diagonal grip. */}
|
|
501
|
+
<div className="qc-popup-resize qc-popup-resize-left" title={t('runResize')} {...resizeProps('left')} />
|
|
502
|
+
{heightFree === 'top'
|
|
503
|
+
? <div className="qc-popup-resize qc-popup-resize-top" title={t('runResize')} {...resizeProps('height')} />
|
|
504
|
+
: <div className="qc-popup-resize qc-popup-resize-bottom" title={t('runResize')} {...resizeProps('height')} />}
|
|
505
|
+
{heightFree === 'top' ? (
|
|
506
|
+
<div className="qc-popup-resize qc-popup-resize-corner qc-popup-resize-corner-tl" title={t('runResize')} {...resizeProps('corner')}>
|
|
507
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
508
|
+
<path
|
|
509
|
+
d="M9 9 3 3 M9 5.5 5.5 3 M5.5 9 3 5.5"
|
|
510
|
+
fill="none"
|
|
511
|
+
stroke="currentColor"
|
|
512
|
+
strokeWidth="1.4"
|
|
513
|
+
strokeLinecap="round"
|
|
514
|
+
strokeLinejoin="round"
|
|
515
|
+
/>
|
|
516
|
+
</svg>
|
|
517
|
+
</div>
|
|
518
|
+
) : (
|
|
519
|
+
<div className="qc-popup-resize qc-popup-resize-corner qc-popup-resize-corner-bl" title={t('runResize')} {...resizeProps('corner')}>
|
|
520
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
521
|
+
<path
|
|
522
|
+
d="M9 3 3 9 M9 6.5 6.5 9 M5.5 3 3 5.5"
|
|
523
|
+
fill="none"
|
|
524
|
+
stroke="currentColor"
|
|
525
|
+
strokeWidth="1.4"
|
|
526
|
+
strokeLinecap="round"
|
|
527
|
+
strokeLinejoin="round"
|
|
528
|
+
/>
|
|
529
|
+
</svg>
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
</div>
|
|
533
|
+
)
|
|
534
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-quick-commands client half.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - `settings.plugin.item` card (keyed by the `quick-commands` settings
|
|
6
|
+
* namespace) — per-workspace command CRUD + popup anchor preference.
|
|
7
|
+
* - `conversation.session.header.utilities` entry (id `quick-commands`,
|
|
8
|
+
* order -1 → left of the Session log pill) — the quick-run ▶ button, command
|
|
9
|
+
* menu, and live output popup.
|
|
10
|
+
*/
|
|
11
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
15
|
+
import { QuickCommandsHeaderAction } from './header'
|
|
16
|
+
import { QuickCommandsSettingsCard } from './settings-card'
|
|
17
|
+
import { LOCALE_NS, zh, en } from './locales'
|
|
18
|
+
import './quick-commands.css'
|
|
19
|
+
|
|
20
|
+
/** Services required before mounting (provided by the client runtime). */
|
|
21
|
+
export const inject = ['slots', 'locale']
|
|
22
|
+
|
|
23
|
+
/** Client plugin body. */
|
|
24
|
+
export function apply(ctx: ClientContext): void {
|
|
25
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'quick-commands: dictionaries')
|
|
26
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
27
|
+
|
|
28
|
+
// The plugin's own Settings Card (设置 → 插件配置) rides the `quick-commands`
|
|
29
|
+
// settings namespace: registering into the keyed `settings.plugin.item` slot
|
|
30
|
+
// with the namespace string makes the configurable-plugins tab dispatch the
|
|
31
|
+
// card next to the built-in ones (bash / agent loop / web search).
|
|
32
|
+
ctx.slots.inject('settings.plugin.item', () =>
|
|
33
|
+
ctx.slots.register({
|
|
34
|
+
name: 'settings.plugin.item',
|
|
35
|
+
key: LOCALE_NS,
|
|
36
|
+
locale: LOCALE_NS,
|
|
37
|
+
}, QuickCommandsSettingsCard as never),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Session-header util: order -1 renders LEFT of the Session log pill
|
|
41
|
+
// (session-log-download registers with default order 0).
|
|
42
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
43
|
+
ctx.slots.register({
|
|
44
|
+
name: 'conversation.session.header.utilities',
|
|
45
|
+
id: 'quick-commands',
|
|
46
|
+
order: -1,
|
|
47
|
+
locale: LOCALE_NS,
|
|
48
|
+
}, QuickCommandsHeaderAction as never),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client dictionary namespace for @fonlan/dsh-quick-commands.
|
|
3
|
+
*/
|
|
4
|
+
export const LOCALE_NS = 'quick-commands'
|
|
5
|
+
|
|
6
|
+
export const zh = {
|
|
7
|
+
// header button
|
|
8
|
+
buttonTitle: '快速命令',
|
|
9
|
+
buttonTooltip: '快速命令(当前工作区)',
|
|
10
|
+
noWorkspace: '当前会话不在任何已配置的工作区中',
|
|
11
|
+
noCommands: '当前工作区未配置命令',
|
|
12
|
+
openSettings: '去设置配置命令',
|
|
13
|
+
|
|
14
|
+
// command popup menu
|
|
15
|
+
menuTitle: '快速命令',
|
|
16
|
+
menuEmpty: '(无命令)',
|
|
17
|
+
|
|
18
|
+
// run popup
|
|
19
|
+
runTitle: '运行中',
|
|
20
|
+
runExited: '已退出',
|
|
21
|
+
runExitCode: '退出码',
|
|
22
|
+
runSignal: '信号',
|
|
23
|
+
runStdout: '输出 (stdout)',
|
|
24
|
+
runStderr: '错误 (stderr)',
|
|
25
|
+
runEmpty: '(无输出)',
|
|
26
|
+
runCommand: '命令',
|
|
27
|
+
runCwd: '工作区',
|
|
28
|
+
runStateRunning: '运行中…',
|
|
29
|
+
runStateExited: '已退出',
|
|
30
|
+
runKill: '终止',
|
|
31
|
+
runClose: '关闭',
|
|
32
|
+
runResize: '拖动调整弹窗大小',
|
|
33
|
+
runKillConfirm: '终止后输出弹窗将关闭,确定终止该命令?',
|
|
34
|
+
runBusy: '该工作区已有命令在运行',
|
|
35
|
+
runStartFailed: '命令启动失败',
|
|
36
|
+
runPollFailed: '输出读取失败',
|
|
37
|
+
runLost: '运行记录已失效',
|
|
38
|
+
runNotFound: '工作区或命令未找到',
|
|
39
|
+
|
|
40
|
+
// settings card
|
|
41
|
+
cardTitle: '快速命令',
|
|
42
|
+
cardDescription: '为每个工作区配置常用命令,会话头部一键运行',
|
|
43
|
+
collapseOrExpand: '展开 / 收起',
|
|
44
|
+
cardIntro: '为每个工作区配置常用命令,运行于工作区目录。支持占位符:{workspace} 工作区路径,{cwd} 会话目录,{title} 工作区标题。',
|
|
45
|
+
cardWorkspaces: '工作区',
|
|
46
|
+
cardNoWorkspaces: '没有工作区。请在侧边栏先添加工作区。',
|
|
47
|
+
cardCommands: '命令',
|
|
48
|
+
cardNoCommands: '未配置命令',
|
|
49
|
+
cardAddCommand: '添加命令',
|
|
50
|
+
cardName: '名称',
|
|
51
|
+
cardCommand: '命令',
|
|
52
|
+
cardRemove: '删除',
|
|
53
|
+
cardSaveError: '保存失败',
|
|
54
|
+
cardAnchor: '输出弹窗位置',
|
|
55
|
+
cardAnchorCorner: '右下角',
|
|
56
|
+
cardAnchorButton: '锚定按钮下方',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const en = {
|
|
60
|
+
buttonTitle: 'Quick commands',
|
|
61
|
+
buttonTooltip: 'Quick commands (current workspace)',
|
|
62
|
+
noWorkspace: 'This session is not in any configured workspace',
|
|
63
|
+
noCommands: 'No commands configured for this workspace',
|
|
64
|
+
openSettings: 'Configure commands in Settings',
|
|
65
|
+
|
|
66
|
+
menuTitle: 'Quick commands',
|
|
67
|
+
menuEmpty: '(no commands)',
|
|
68
|
+
|
|
69
|
+
runTitle: 'Running',
|
|
70
|
+
runExited: 'Exited',
|
|
71
|
+
runExitCode: 'Exit code',
|
|
72
|
+
runSignal: 'Signal',
|
|
73
|
+
runStdout: 'Output (stdout)',
|
|
74
|
+
runStderr: 'Errors (stderr)',
|
|
75
|
+
runEmpty: '(no output)',
|
|
76
|
+
runCommand: 'Command',
|
|
77
|
+
runCwd: 'Workspace',
|
|
78
|
+
runStateRunning: 'Running…',
|
|
79
|
+
runStateExited: 'Exited',
|
|
80
|
+
runKill: 'Terminate',
|
|
81
|
+
runClose: 'Close',
|
|
82
|
+
runResize: 'Drag to resize the popup',
|
|
83
|
+
runKillConfirm: 'Terminating closes the output popup. Terminate this command?',
|
|
84
|
+
runBusy: 'A command is already running for this workspace',
|
|
85
|
+
runStartFailed: 'Command failed to start',
|
|
86
|
+
runPollFailed: 'Failed to read output',
|
|
87
|
+
runLost: 'Run record expired',
|
|
88
|
+
runNotFound: 'Workspace or command not found',
|
|
89
|
+
|
|
90
|
+
cardTitle: 'Quick commands',
|
|
91
|
+
cardDescription: 'Configure common commands per workspace, run them from the session header',
|
|
92
|
+
collapseOrExpand: 'Expand / collapse',
|
|
93
|
+
cardIntro: 'Configure common commands per workspace; they run in the workspace directory. Placeholders: {workspace} workspace path, {cwd} session cwd, {title} workspace title.',
|
|
94
|
+
cardWorkspaces: 'Workspaces',
|
|
95
|
+
cardNoWorkspaces: 'No workspaces. Add one in the sidebar first.',
|
|
96
|
+
cardCommands: 'Commands',
|
|
97
|
+
cardNoCommands: 'No commands configured',
|
|
98
|
+
cardAddCommand: 'Add command',
|
|
99
|
+
cardName: 'Name',
|
|
100
|
+
cardCommand: 'Command',
|
|
101
|
+
cardRemove: 'Remove',
|
|
102
|
+
cardSaveError: 'Save failed',
|
|
103
|
+
cardAnchor: 'Output popup position',
|
|
104
|
+
cardAnchorCorner: 'Bottom right',
|
|
105
|
+
cardAnchorButton: 'Anchored at button',
|
|
106
|
+
}
|