@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.
@@ -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
+ }