@workerdeck/ui 2.0.0 → 2.2.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.
@@ -1,4 +1,14 @@
1
- import { useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, type ReactNode, type Ref } from 'react'
1
+ import {
2
+ useEffect,
3
+ useImperativeHandle,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type DragEvent,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ type Ref,
11
+ } from 'react'
2
12
  import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
3
13
  import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
4
14
  import { ArrowUp, FileText, Paperclip, RotateCw, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
@@ -7,7 +17,7 @@ import { Spinner } from '../ui/Spinner.tsx'
7
17
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
8
18
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
9
19
  import { plainTextToSegments } from '../prompt-area/prompt-area-engine.ts'
10
- import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
20
+ import { commandTrigger, launchTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
11
21
  import { useTranscriptVariant } from './transcript-variant.tsx'
12
22
  import type { TerminalAffordances } from '../terminal/affordances.tsx'
13
23
  import { PROMPT_GLYPH } from '../terminal/items.tsx'
@@ -18,6 +28,9 @@ import { formatBytes } from '../../lib/format.ts'
18
28
 
19
29
  export type ComposerFileMatch = { path: string; relative: string }
20
30
 
31
+ const SHELL_PLACEHOLDER = 'Run a command on the host…'
32
+ const SHELL_HINT = '! shell mode · esc to exit'
33
+
21
34
  export type ComposerHandle = {
22
35
  insertText: (text: string) => void
23
36
  focus: () => void
@@ -32,6 +45,9 @@ export interface ComposerProps {
32
45
  commands?: SlashCommandInfo[]
33
46
  skills?: SkillInfo[]
34
47
  onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
48
+ // Shell mode: `!` as the first character turns the composer into a host shell prompt. Omit to leave the
49
+ // mode off entirely — the gateway only offers it to an operator on a session whose engine reaches a host cwd.
50
+ onShellCommand?: (command: string) => void
35
51
  attachments?: UseAttachmentsResult
36
52
  toolbar?: ReactNode
37
53
  layout?: 'stacked' | 'inline'
@@ -72,6 +88,7 @@ export function Composer({
72
88
  commands,
73
89
  skills,
74
90
  onSearchFiles,
91
+ onShellCommand,
75
92
  attachments,
76
93
  toolbar,
77
94
  layout = 'stacked',
@@ -91,6 +108,7 @@ export function Composer({
91
108
  )
92
109
  const fileInput = useRef<HTMLInputElement>(null)
93
110
  const [dragging, setDragging] = useState(false)
111
+ const [shellMode, setShellMode] = useState(false)
94
112
 
95
113
  useImperativeHandle(
96
114
  ref,
@@ -196,8 +214,17 @@ export function Composer({
196
214
  }),
197
215
  )
198
216
  }
217
+ if (onShellCommand) {
218
+ configured.push(
219
+ launchTrigger({
220
+ char: '!',
221
+ accessibilityLabel: 'shell mode',
222
+ onActivate: () => setShellMode(true),
223
+ }),
224
+ )
225
+ }
199
226
  return configured.length > 0 ? configured : undefined
200
- }, [commands, skills, onSearchFiles])
227
+ }, [commands, skills, onSearchFiles, onShellCommand])
201
228
 
202
229
  const saveDraft = draft?.save
203
230
  useEffect(() => {
@@ -211,6 +238,18 @@ export function Composer({
211
238
  if (!canSend) {
212
239
  return
213
240
  }
241
+ if (shellMode) {
242
+ const command = plainText.trim()
243
+ if (command === '') {
244
+ return
245
+ }
246
+ onShellCommand?.(command)
247
+ setShellMode(false)
248
+ draft?.clear()
249
+ clear()
250
+ focus()
251
+ return
252
+ }
214
253
  onSend(plainText.trim(), attachments?.readyIds ?? [])
215
254
  attachments?.clear()
216
255
  draft?.clear()
@@ -218,6 +257,24 @@ export function Composer({
218
257
  focus()
219
258
  }
220
259
 
260
+ const leaveShellMode = () => {
261
+ setShellMode(false)
262
+ focus()
263
+ }
264
+ // Escape and backspace-on-empty both leave, because both are what a person reaches for when the
265
+ // pink frame was not what they meant. Backspace only when there is nothing left to delete.
266
+ const shellKeys = shellMode
267
+ ? {
268
+ onEscape: leaveShellMode,
269
+ onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
270
+ if (e.key === 'Backspace' && isEmpty) {
271
+ e.preventDefault()
272
+ leaveShellMode()
273
+ }
274
+ },
275
+ }
276
+ : {}
277
+
221
278
  const pick = (files: FileList | null) => {
222
279
  if (files && files.length > 0) {
223
280
  attachments?.add(files)
@@ -250,7 +307,11 @@ export function Composer({
250
307
  </>
251
308
  ) : null
252
309
 
253
- const gutter = busy ? (
310
+ const gutter = shellMode ? (
311
+ <GlyphButton gutter label="Leave shell mode" tone="magenta" onClick={leaveShellMode}>
312
+ !
313
+ </GlyphButton>
314
+ ) : busy ? (
254
315
  <GlyphButton gutter label="Interrupt" tone="yellow" onClick={onInterrupt}>
255
316
  ✕
256
317
  </GlyphButton>
@@ -318,12 +379,12 @@ export function Composer({
318
379
  affordances={affordances}
319
380
  bleed="1ch"
320
381
  data-dragging={dragging || undefined}
321
- className={cn('term-composer', disabled && 'opacity-60')}
382
+ className={cn('term-composer', shellMode && 'term-composer-shell', disabled && 'opacity-60')}
322
383
  >
323
384
  <div className="term-composer-body">
324
385
  {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
325
386
  <div className="term-row">
326
- {canAttach ? fileField : null}
387
+ {canAttach && !shellMode ? fileField : null}
327
388
  {gutter}
328
389
  <div className="flex min-w-0 items-start">
329
390
  <PromptArea
@@ -333,17 +394,23 @@ export function Composer({
333
394
  normalizeBullets={false}
334
395
  onSubmit={submit}
335
396
  disabled={disabled}
336
- placeholder={disabled ? 'Session ended' : placeholder}
397
+ placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
337
398
  minHeight={line}
338
399
  maxHeight={line * 10}
339
- aria-label="Message the agent"
400
+ aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
340
401
  className="term-composer-field min-w-0 flex-1"
341
402
  onImagePaste={(file) => attachments?.add([file])}
403
+ {...shellKeys}
342
404
  />
343
405
  {submitButton}
344
406
  </div>
345
407
  </div>
346
- {toolbar ? (
408
+ {shellMode ? (
409
+ <div className="term-row">
410
+ <span aria-hidden className="term-gutter" />
411
+ <span className="term-composer-shell-hint">{SHELL_HINT}</span>
412
+ </div>
413
+ ) : toolbar ? (
347
414
  <div className="term-row">
348
415
  <span aria-hidden className="term-gutter" />
349
416
  <div className="flex min-w-0 items-center gap-[1ch]">{toolbar}</div>
@@ -365,13 +432,15 @@ export function Composer({
365
432
  'transition-colors rounded-lg shadow-(--shadow-xs)',
366
433
  'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
367
434
  dragging && 'border-ring ring-2 ring-ring/30',
435
+ shellMode &&
436
+ 'border-[var(--wd-shell-accent)] focus-within:border-[var(--wd-shell-accent)] focus-within:ring-[var(--wd-shell-accent)]/30',
368
437
  disabled && 'opacity-60',
369
438
  )}
370
439
  >
371
440
  {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
372
441
  {inline ? (
373
442
  <div className="flex items-end gap-1 p-1">
374
- {attach}
443
+ {shellMode ? <ShellBadge onLeave={leaveShellMode} /> : attach}
375
444
  <PromptArea
376
445
  {...bind}
377
446
  triggers={triggers}
@@ -379,12 +448,13 @@ export function Composer({
379
448
  normalizeBullets={false}
380
449
  onSubmit={submit}
381
450
  disabled={disabled}
382
- placeholder={disabled ? 'Session ended' : placeholder}
451
+ placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
383
452
  minHeight={20}
384
453
  maxHeight={192}
385
- aria-label="Message the agent"
454
+ aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
386
455
  className="min-w-0 flex-1 py-1 text-body-sm text-text"
387
456
  onImagePaste={(file) => attachments?.add([file])}
457
+ {...shellKeys}
388
458
  />
389
459
  {submitButton}
390
460
  </div>
@@ -397,17 +467,18 @@ export function Composer({
397
467
  normalizeBullets={false}
398
468
  onSubmit={submit}
399
469
  disabled={disabled}
400
- placeholder={disabled ? 'Session ended' : placeholder}
470
+ placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
401
471
  minHeight={28}
402
472
  maxHeight={192}
403
- aria-label="Message the agent"
473
+ aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
404
474
  className="px-3 pt-2.5 pb-0 text-body-sm text-text"
405
475
  onImagePaste={(file) => attachments?.add([file])}
476
+ {...shellKeys}
406
477
  />
407
478
  <div className="flex items-center justify-between gap-2 px-2 pb-2">
408
479
  <div className="flex min-w-0 items-center gap-1">
409
- {attach}
410
- {toolbar}
480
+ {shellMode ? <ShellBadge onLeave={leaveShellMode} /> : attach}
481
+ {shellMode ? <span className="text-label text-text-muted">{SHELL_HINT}</span> : toolbar}
411
482
  </div>
412
483
  {submitButton}
413
484
  </div>
@@ -428,7 +499,7 @@ function GlyphButton({
428
499
  className,
429
500
  children,
430
501
  }: {
431
- tone?: 'blue' | 'yellow'
502
+ tone?: 'blue' | 'yellow' | 'magenta'
432
503
  label: string
433
504
  disabled?: boolean
434
505
  onClick: () => void
@@ -526,3 +597,17 @@ function extensionOf(name: string) {
526
597
  const dot = name.lastIndexOf('.')
527
598
  return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
528
599
  }
600
+
601
+ function ShellBadge({ onLeave }: { onLeave: () => void }) {
602
+ return (
603
+ <button
604
+ type="button"
605
+ aria-label="Leave shell mode"
606
+ title="Leave shell mode"
607
+ onClick={onLeave}
608
+ className="shrink-0 rounded px-1.5 py-0.5 font-mono text-label font-semibold text-[var(--wd-shell-accent)]"
609
+ >
610
+ !
611
+ </button>
612
+ )
613
+ }
@@ -50,7 +50,6 @@ import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from
50
50
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
51
51
  import { StatusBar } from './StatusBar.tsx'
52
52
  import { Transcript } from './Transcript.tsx'
53
- import { HeldSendsBar, useHeldSends } from './held-sends.tsx'
54
53
  import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
55
54
  import { ToolTitleProvider } from './tool-titles.tsx'
56
55
  import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
@@ -116,9 +115,6 @@ export interface SessionPanelProps {
116
115
  stickyPrompt?: boolean
117
116
  transcriptDensity?: TranscriptDensity
118
117
  transcriptFont?: TranscriptFont
119
- // 'fold' sends a mid-turn message straight through, and the engine folds it into the running
120
- // turn (catch-up mode). 'hold' keeps it here until the turn ends.
121
- midTurnSend?: 'fold' | 'hold'
122
118
  controlsSurface?: 'internal' | 'external' | 'status'
123
119
  onControls?: (controls: SessionControls | undefined) => void
124
120
  focusComposerOnClick?: boolean
@@ -194,7 +190,6 @@ export function SessionPanel({
194
190
  transcriptVariant = 'cards',
195
191
  transcriptDensity = 'comfortable',
196
192
  transcriptFont = 'sans',
197
- midTurnSend = 'fold',
198
193
  affordances,
199
194
  terminalMetrics,
200
195
  scrubber = false,
@@ -234,10 +229,12 @@ export function SessionPanel({
234
229
  connection,
235
230
  replaying,
236
231
  protocolMismatch,
232
+ shell,
237
233
  models,
238
234
  effectiveModel,
239
235
  handle,
240
236
  send,
237
+ runShell,
241
238
  approve,
242
239
  deny,
243
240
  interrupt,
@@ -416,8 +413,6 @@ export function SessionPanel({
416
413
  return [{ name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' }, ...state.commands]
417
414
  }, [state.commands])
418
415
 
419
- const heldSends = useHeldSends({ hold: midTurnSend === 'hold', busy: busy && !ended, send })
420
-
421
416
  const handleSend = (text: string, attachmentIds: string[]) => {
422
417
  if (attachmentIds.length === 0) {
423
418
  const modelCommand = /^\/model\s+(\S+)$/.exec(text)
@@ -432,7 +427,7 @@ export function SessionPanel({
432
427
  }
433
428
  setCaughtUp(true)
434
429
  repinTranscript.current?.()
435
- heldSends.submit(text, attachmentIds)
430
+ send(text, attachmentIds)
436
431
  }
437
432
 
438
433
  const actionsMenu = (
@@ -691,7 +686,6 @@ export function SessionPanel({
691
686
  ) : null}
692
687
  {readOnly || subagentId !== undefined ? null : (
693
688
  <>
694
- <HeldSendsBar held={heldSends.held} onSendNow={heldSends.flush} />
695
689
  <Composer
696
690
  ref={composerRef}
697
691
  onSend={handleSend}
@@ -703,6 +697,7 @@ export function SessionPanel({
703
697
  attachments={attachments}
704
698
  draft={draft}
705
699
  onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
700
+ onShellCommand={shell ? runShell : undefined}
706
701
  layout={controlsExternal ? 'inline' : 'stacked'}
707
702
  toolbar={controlsExternal ? undefined : sessionControls}
708
703
  fontSize={effectiveTermFontSize}
@@ -19,7 +19,6 @@ export interface SessionWorkspaceProps {
19
19
  transcriptVariant?: SessionPanelProps['transcriptVariant']
20
20
  transcriptDensity?: SessionPanelProps['transcriptDensity']
21
21
  transcriptFont?: SessionPanelProps['transcriptFont']
22
- midTurnSend?: SessionPanelProps['midTurnSend']
23
22
  scrubber?: SessionPanelProps['scrubber']
24
23
  bookmarks?: SessionPanelProps['bookmarks']
25
24
  onToggleBookmark?: SessionPanelProps['onToggleBookmark']
@@ -53,7 +52,6 @@ export function SessionWorkspace({
53
52
  transcriptVariant,
54
53
  transcriptDensity,
55
54
  transcriptFont,
56
- midTurnSend,
57
55
  scrubber,
58
56
  bookmarks,
59
57
  onToggleBookmark,
@@ -213,7 +211,6 @@ export function SessionWorkspace({
213
211
  transcriptVariant={transcriptVariant}
214
212
  transcriptDensity={transcriptDensity}
215
213
  transcriptFont={transcriptFont}
216
- midTurnSend={midTurnSend}
217
214
  scrubber={scrubber}
218
215
  bookmarks={bookmarks}
219
216
  onToggleBookmark={onToggleBookmark}
@@ -5,6 +5,6 @@
5
5
  */
6
6
  export { PromptArea } from './prompt-area.tsx'
7
7
  export { usePromptAreaState } from './use-prompt-area-state.ts'
8
- export { commandTrigger, mentionTrigger, hashtagTrigger } from './trigger-presets.ts'
8
+ export { commandTrigger, mentionTrigger, hashtagTrigger, launchTrigger } from './trigger-presets.ts'
9
9
  export { segmentsToPlainText, plainTextToSegments, isSegmentsEmpty, getChipsByTrigger } from './segment-helpers.ts'
10
10
  export type { PromptAreaHandle, PromptAreaProps, Segment, ChipSegment, TextSegment, TriggerConfig, TriggerSuggestion } from './types.ts'
@@ -131,3 +131,27 @@ export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
131
131
  ...rest,
132
132
  }
133
133
  }
134
+
135
+ // Launch trigger — fires onActivate and swallows the character
136
+
137
+ export type LaunchTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
138
+ /** The trigger character. Required. */
139
+ char: string
140
+ }
141
+
142
+ /**
143
+ * Creates a **launch** trigger: `onActivate` fires on keydown and the character
144
+ * never enters the editor. For flipping the composer into another mode, or
145
+ * opening a surface outside it.
146
+ *
147
+ * Defaults: `position: 'start'`, `mode: 'launch'`.
148
+ */
149
+ export function launchTrigger(opts: LaunchTriggerOptions): TriggerConfig {
150
+ const { char, ...rest } = opts
151
+ return {
152
+ char,
153
+ position: 'start',
154
+ mode: 'launch',
155
+ ...rest,
156
+ }
157
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  commandTrigger,
29
29
  mentionTrigger,
30
30
  hashtagTrigger,
31
+ launchTrigger,
31
32
  segmentsToPlainText,
32
33
  plainTextToSegments,
33
34
  isSegmentsEmpty,
@@ -75,7 +76,6 @@ export { Reasoning, type ReasoningProps } from './components/agent/Reasoning.tsx
75
76
  export { Loader } from './components/agent/Loader.tsx'
76
77
  export { ToolCallCard, type ToolCallCardProps, type ToolCallItem } from './components/agent/ToolCallCard.tsx'
77
78
  export { ToolTitleProvider, useToolTitle } from './components/agent/tool-titles.tsx'
78
- export { HeldSendsBar, useHeldSends, type HeldSend, type HeldSends } from './components/agent/held-sends.tsx'
79
79
  export { FileCard, type FileCardProps, type FileDeliveredItem } from './components/agent/FileCard.tsx'
80
80
  export { PermissionPrompt, type PermissionPromptProps } from './components/agent/PermissionPrompt.tsx'
81
81
  export {
@@ -643,6 +643,17 @@
643
643
  border-color: var(--term-blue);
644
644
  }
645
645
 
646
+ /* Shell mode. The frame and the gutter glyph move together and both go magenta, because the thing
647
+ the mode changes is where the text goes — a subtle cue would be the wrong kind of quiet. Listed
648
+ after the focus rule so it wins while the field is focused, which is the whole time it is on. */
649
+ [data-terminal].term-composer-shell,
650
+ [data-terminal].term-composer-shell:focus-within {
651
+ border-color: var(--term-magenta);
652
+ }
653
+ [data-terminal] .term-composer-shell-hint {
654
+ color: var(--term-dim);
655
+ }
656
+
646
657
  /* The contentEditable field. `font: inherit` is what makes it the cell — the
647
658
  surface above already carries the size and the line height, and anything that
648
659
  restates them here can disagree with the rows. */
@@ -155,6 +155,10 @@
155
155
  and every other colour on the rail is already spoken for. */
156
156
  --wd-scrub-bookmark: #8b3a8b;
157
157
 
158
+ /* Shell mode's frame and glyph. The same magenta as the bookmark rail, for the same reason: the app
159
+ palette has no magenta, and shell mode must not read as focus (blue) or as an error (red). */
160
+ --wd-shell-accent: #8b3a8b;
161
+
158
162
  /* ---------- aliases (bridge for the @theme utilities) ---------- */
159
163
  --surface: var(--bg-surface);
160
164
  --sidebar: #f4f4f4;
@@ -238,6 +242,7 @@
238
242
 
239
243
  /* See the light block — lifted for the dark ground. */
240
244
  --wd-scrub-bookmark: #c586c0;
245
+ --wd-shell-accent: #c586c0;
241
246
 
242
247
  /* ---------- aliases ---------- */
243
248
  --surface: var(--bg-surface);