@workerdeck/ui 2.2.1 → 2.3.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,11 +1,16 @@
1
1
  import { useState } from 'react'
2
- import type { PermissionRequest, QuestionBehavior, UserQuestion, UserQuestionOption } from '@workerdeck/protocol'
2
+ import { parseUserQuestions, type PermissionRequest, type QuestionBehavior } from '@workerdeck/protocol'
3
+
4
+ // Re-exported, not redefined: the parse moved to protocol so the CLI's Live Activity builder could
5
+ // share it. Kept on this path because `@workerdeck/ui` has published it since 1.0.0.
3
6
  import { MessageCircleQuestion, X } from 'lucide-react'
4
7
  import { Badge } from '../ui/Badge.tsx'
5
8
  import { Button } from '../ui/Button.tsx'
6
- import { Input } from '../ui/Input.tsx'
9
+ import { Textarea } from '../ui/Textarea.tsx'
7
10
  import { cn } from '../../lib/utils.ts'
8
11
 
12
+ export { parseUserQuestions }
13
+
9
14
  export type QuestionBehaviorMeta = {
10
15
  value: QuestionBehavior
11
16
  label: string
@@ -18,28 +23,6 @@ export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
18
23
  { value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
19
24
  ]
20
25
 
21
- export function parseUserQuestions(input: Record<string, unknown>): UserQuestion[] {
22
- const raw = Array.isArray(input.questions) ? input.questions : []
23
- return raw.flatMap((entry): UserQuestion[] => {
24
- const q = entry as Partial<UserQuestion>
25
- if (typeof q.question !== 'string' || !Array.isArray(q.options)) {
26
- return []
27
- }
28
- const options = q.options.filter((o): o is UserQuestionOption => typeof (o as UserQuestionOption | undefined)?.label === 'string')
29
- if (options.length === 0) {
30
- return []
31
- }
32
- return [
33
- {
34
- question: q.question,
35
- header: typeof q.header === 'string' ? q.header : '',
36
- options,
37
- multiSelect: q.multiSelect === true,
38
- },
39
- ]
40
- })
41
- }
42
-
43
26
  type Selection = { labels: string[]; other: string; otherActive: boolean }
44
27
 
45
28
  const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
@@ -138,12 +121,13 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
138
121
  Other…
139
122
  </button>
140
123
  {selection.otherActive ? (
141
- <Input
124
+ <Textarea
142
125
  autoFocus
126
+ rows={1}
143
127
  value={selection.other}
144
128
  onChange={(e) => update(index, { other: e.target.value })}
145
129
  placeholder="Type your own answer"
146
- className="flex-1"
130
+ className="max-h-40 flex-1 overflow-y-auto py-1.5 field-sizing-content"
147
131
  />
148
132
  ) : null}
149
133
  </div>
@@ -1,4 +1,13 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ComponentType,
8
+ type MouseEvent as ReactMouseEvent,
9
+ type ReactNode,
10
+ } from 'react'
2
11
  import type { WorkerDeckClient } from '@workerdeck/client'
3
12
  import {
4
13
  PROTOCOL_VERSION,
@@ -8,6 +17,7 @@ import {
8
17
  usageInfos,
9
18
  type ModelOption,
10
19
  type PermissionMode,
20
+ type PermissionRequest,
11
21
  type RateLimitInfo,
12
22
  type SessionTask,
13
23
  type SkillInfo,
@@ -91,6 +101,14 @@ function PromptSurface({
91
101
 
92
102
  export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
93
103
 
104
+ // Deliberately the built-in prompts' own callback shapes, so `PermissionPrompt` and
105
+ // `TerminalPermissionPrompt` stay drop-in fallbacks for an input a host declines to draw.
106
+ export interface ApprovalPromptProps {
107
+ request: PermissionRequest
108
+ onApprove: (requestId: string, updatedInput?: Record<string, unknown>) => void
109
+ onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
110
+ }
111
+
94
112
  export interface SessionPanelProps {
95
113
  client: WorkerDeckClient
96
114
  sessionId: string | undefined
@@ -120,6 +138,7 @@ export interface SessionPanelProps {
120
138
  focusComposerOnClick?: boolean
121
139
  unseen?: { itemCount: number; since?: number }
122
140
  readOnly?: boolean
141
+ approvalPrompts?: Record<string, ComponentType<ApprovalPromptProps>>
123
142
  toolHost?: UseToolCallHostOptions | false
124
143
  cacheTranscript?: boolean
125
144
  emptyState?: ReactNode
@@ -205,6 +224,7 @@ export function SessionPanel({
205
224
  focusComposerOnClick = false,
206
225
  unseen,
207
226
  readOnly = false,
227
+ approvalPrompts,
208
228
  toolHost,
209
229
  clientTools,
210
230
  cacheTranscript,
@@ -657,6 +677,12 @@ export function SessionPanel({
657
677
  affordances={affordances}
658
678
  >
659
679
  {state.pendingApprovals.map((request) => {
680
+ // Before the variant split: a host's entry is the renderer for its tool in
681
+ // both themes, and it overrides the built-in entries rather than racing them.
682
+ const HostPrompt = approvalPrompts?.[request.toolName]
683
+ if (HostPrompt) {
684
+ return <HostPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
685
+ }
660
686
  const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
661
687
  if (terminal) {
662
688
  return isQuestion ? (
@@ -196,11 +196,7 @@ export function Transcript({
196
196
  />
197
197
  </>
198
198
  ) : (
199
- <Loader
200
- label={state.status === 'starting' ? 'Starting session…' : undefined}
201
- startedAt={runStartedAt}
202
- tokens={state.contextUsage?.totalTokens}
203
- />
199
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
204
200
  )
205
201
  ) : null}
206
202
  </TerminalShell>
@@ -1,7 +1,7 @@
1
1
  import type { MessageAttachment } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
- import { COMPACTION_TEXT, formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
4
+ import { compactionText, formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
5
5
  import { FileCard } from './FileCard.tsx'
6
6
  import { Message, MessageContent } from './Message.tsx'
7
7
  import { PromptTokenText } from './PromptTokenText.tsx'
@@ -100,7 +100,7 @@ export function TranscriptItemView({
100
100
  return <NoticeRow item={item} />
101
101
  }
102
102
  case 'compaction': {
103
- return <CompactionRow />
103
+ return <CompactionRow item={item} />
104
104
  }
105
105
  case 'file_delivered': {
106
106
  return <FileCard item={item} href={fileUrl?.(item.path)} />
@@ -111,11 +111,13 @@ export function TranscriptItemView({
111
111
  }
112
112
  }
113
113
 
114
- function CompactionRow() {
114
+ function CompactionRow({ item }: { item: Extract<TranscriptItem, { kind: 'compaction' }> }) {
115
115
  return (
116
116
  <div data-slot="compaction" className="flex items-center gap-2 py-1">
117
117
  <div className="h-px flex-1 bg-border" />
118
- <span className="font-mono text-label text-fg-3">≡ {COMPACTION_TEXT}</span>
118
+ <span className={`font-mono text-label ${item.error === undefined ? 'text-fg-3' : 'text-danger'}`}>
119
+ {item.pending ? '⋯' : '≡'} {compactionText(item)}
120
+ </span>
119
121
  <div className="h-px flex-1 bg-border" />
120
122
  </div>
121
123
  )
@@ -113,7 +113,11 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
113
113
  }}
114
114
  />
115
115
  <Blank />
116
- <Hint>Enter to select · ↑/↓ to navigate · 1–3 to choose · Esc to cancel</Hint>
116
+ <Hint>
117
+ {denying
118
+ ? 'Enter to send · Shift+Enter for a new line · Esc to go back'
119
+ : 'Enter to select · ↑/↓ to navigate · 1–3 to choose · Esc to cancel'}
120
+ </Hint>
117
121
  </div>
118
122
  )
119
123
  }
@@ -122,7 +122,11 @@ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className
122
122
  />
123
123
  )}
124
124
  <Blank />
125
- <Hint>Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel</Hint>
125
+ <Hint>
126
+ {!review && selection.otherActive
127
+ ? 'Enter to submit · Shift+Enter for a new line · Tab to switch questions · Esc to cancel'
128
+ : 'Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel'}
129
+ </Hint>
126
130
  </div>
127
131
  )
128
132
  }
@@ -53,7 +53,7 @@ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; file
53
53
  return <NoticeRow item={item} />
54
54
  }
55
55
  case 'compaction': {
56
- return <CompactionRow />
56
+ return <CompactionRow item={item} />
57
57
  }
58
58
  case 'file_delivered': {
59
59
  return <FileRow item={item} href={fileUrl?.(item.path)} />
@@ -1,6 +1,6 @@
1
1
  import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { COMPACTION_TEXT, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
+ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
5
5
  import { IMAGE_BOX_LINES } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
@@ -623,7 +623,7 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
623
623
  return rowH(item.text, m, { extraPx })
624
624
  }
625
625
  case 'compaction': {
626
- return rowH(COMPACTION_TEXT, m, { extraPx })
626
+ return rowH(compactionText(item), m, { extraPx })
627
627
  }
628
628
  case 'file_delivered': {
629
629
  const text = `${item.path} · ${formatBytes(item.bytes)}` + (item.description ? ` · ${item.description}` : '')
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { COMPACTION_TEXT, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
+ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { isMutatingTool } from '../../lib/tool-icon.ts'
5
5
  import { usePulse } from '../agent/pulse.tsx'
6
6
  import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
@@ -281,10 +281,11 @@ export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind:
281
281
  )
282
282
  }
283
283
 
284
- export function CompactionRow() {
284
+ export function CompactionRow({ item }: { item: Extract<TranscriptItem, { kind: 'compaction' }> }) {
285
+ const failed = item.error !== undefined
285
286
  return (
286
- <Row glyph="≡" glyphTone="yellow" tone="faint">
287
- {COMPACTION_TEXT}
287
+ <Row glyph={item.pending ? '⋯' : '≡'} glyphTone={failed ? 'red' : 'yellow'} tone={failed ? 'red' : 'faint'}>
288
+ {compactionText(item)}
288
289
  </Row>
289
290
  )
290
291
  }
@@ -162,13 +162,17 @@ export function PromptInput({
162
162
  }) {
163
163
  return (
164
164
  <Row columns={5} glyph=" ›" glyphTone="dim">
165
- <input
165
+ <textarea
166
166
  autoFocus
167
+ rows={1}
167
168
  value={value}
168
169
  placeholder={placeholder}
169
170
  onChange={(event) => onChange(event.target.value)}
170
171
  onKeyDown={(event) => {
171
- if (event.key === 'Enter') {
172
+ // The composer's contract, so the send key means the same thing everywhere:
173
+ // Enter sends, Shift+Enter is the newline. `isComposing` keeps an IME's
174
+ // candidate-confirming Enter from submitting a half-typed answer.
175
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
172
176
  event.preventDefault()
173
177
  onSubmit()
174
178
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export {
44
44
 
45
45
  export {
46
46
  SessionPanel,
47
+ type ApprovalPromptProps,
47
48
  type SessionControls,
48
49
  type SessionPanelProps,
49
50
  type SessionSurfacePanel,
package/src/lib/format.ts CHANGED
@@ -1,6 +1,31 @@
1
1
  // Here rather than beside the row: height.ts must measure the string items.tsx draws, and cannot import a .tsx.
2
2
  export const COMPACTION_TEXT = 'context compacted · earlier turns summarised to fit the window'
3
3
 
4
+ export function compactionText(item: {
5
+ pending?: boolean
6
+ trigger?: 'manual' | 'auto'
7
+ preTokens?: number
8
+ postTokens?: number
9
+ error?: string
10
+ }): string {
11
+ if (item.error) {
12
+ return `context compaction failed · ${item.error}`
13
+ }
14
+ if (item.pending) {
15
+ return 'compacting context · summarising earlier turns to fit the window'
16
+ }
17
+ const window =
18
+ item.preTokens === undefined
19
+ ? undefined
20
+ : item.postTokens === undefined
21
+ ? `from ${formatTokens(item.preTokens)}`
22
+ : `${formatTokens(item.preTokens)} → ${formatTokens(item.postTokens)}`
23
+ // The trigger is worth a word only when nobody asked for it: an automatic compaction mid-turn
24
+ // is the one a reader is surprised by.
25
+ const auto = item.trigger === 'auto' ? 'automatic' : undefined
26
+ return [COMPACTION_TEXT, window, auto].filter(Boolean).join(' · ')
27
+ }
28
+
4
29
  export function formatCost(usd: number | undefined): string {
5
30
  if (usd === undefined || Number.isNaN(usd)) {
6
31
  return '—'
@@ -532,6 +532,19 @@
532
532
  caret-animation: manual;
533
533
  background: linear-gradient(var(--term-faint), var(--term-faint)) bottom left / 100% 1px no-repeat;
534
534
  }
535
+ /* The same field over more than one line. It stays on the grid for free: there is
536
+ no padding and `line-height` is `--term-line`, so the content box is always a
537
+ whole number of lines and the rule under it lands on a cell boundary. The cap is
538
+ lines too, not a pixel height. Where `field-sizing` is unsupported this is a
539
+ one-line box that scrolls — the old single-line behaviour, minus the sideways
540
+ crawl, so the degradation costs nothing that was there before. */
541
+ [data-terminal] textarea.term-input {
542
+ display: block;
543
+ field-sizing: content;
544
+ resize: none;
545
+ overflow-y: auto;
546
+ max-height: calc(8 * var(--term-line));
547
+ }
535
548
  [data-terminal] .term-input:focus {
536
549
  background-image: linear-gradient(var(--term-blue), var(--term-blue));
537
550
  }
@@ -559,8 +572,8 @@
559
572
  * top, not a line early.
560
573
  *
561
574
  * The lane spans the whole turn and the head is an overlay, so both are
562
- * transparent to the pointer — the real row beneath owns interaction, and
563
- * the head is `aria-hidden` in the markup. The z-index is on the lane —
575
+ * transparent to the pointer — the rows it covers own interaction, and the
576
+ * head is `aria-hidden` in the markup. The z-index is on the lane —
564
577
  * every row wrapper is its own stacking context (they are transformed), so
565
578
  * the head's own z-index could never beat a later sibling.
566
579
  *
@@ -575,6 +588,15 @@
575
588
  pointer-events: none;
576
589
  }
577
590
 
591
+ /* The prompt itself is *inside* the lane, not beneath it, so the lane's `none`
592
+ reached it too and made a user message the one block in the transcript the
593
+ pointer could not touch: a drag started on it landed on the lane instead and
594
+ selected nothing. Only the real row is given back — the head overlay above
595
+ stays transparent, which is the whole point of the lane. */
596
+ [data-terminal] [data-sticky-lane] > [data-index] {
597
+ pointer-events: auto;
598
+ }
599
+
578
600
  /* The head's own strip: the full lane, out of flow, so the head needs no
579
601
  footprint-cancelling margin — sticky confinement clamps the margin box, and
580
602
  a negative margin shrinks it to nothing, letting the head overshoot the