@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.
- package/build/{SessionPanel-ErvQUyHe.mjs → SessionPanel-B5FfiBxE.mjs} +45 -41
- package/build/SessionPanel-B5FfiBxE.mjs.map +1 -0
- package/build/{SessionPanel-My98j9RZ.d.mts → SessionPanel-_lQLhWOw.d.mts} +11 -5
- package/build/{format-TVRJzYUj.d.mts → format-eS_oUry-.d.mts} +9 -2
- package/build/format.d.mts +2 -2
- package/build/format.mjs +2 -2
- package/build/index.d.mts +5 -8
- package/build/index.mjs +3 -12
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +13 -0
- package/build/{status-C4TAgVSc.mjs → status-BO9wJloK.mjs} +13 -2
- package/build/status-BO9wJloK.mjs.map +1 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +2 -2
- package/package.json +4 -4
- package/src/components/agent/Loader.tsx +0 -2
- package/src/components/agent/QuestionPrompt.tsx +10 -26
- package/src/components/agent/SessionPanel.tsx +27 -1
- package/src/components/agent/Transcript.tsx +1 -5
- package/src/components/agent/TranscriptItemView.tsx +6 -4
- package/src/components/terminal/PermissionPrompt.tsx +5 -1
- package/src/components/terminal/QuestionPrompt.tsx +5 -1
- package/src/components/terminal/TerminalTranscript.tsx +1 -1
- package/src/components/terminal/height.ts +2 -2
- package/src/components/terminal/items.tsx +5 -4
- package/src/components/terminal/prompt.tsx +6 -2
- package/src/index.ts +1 -0
- package/src/lib/format.ts +25 -0
- package/src/styles/terminal.css +24 -2
- package/build/SessionPanel-ErvQUyHe.mjs.map +0 -1
- package/build/status-C4TAgVSc.mjs.map +0 -1
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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 {
|
|
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=
|
|
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>
|
|
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>
|
|
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 {
|
|
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(
|
|
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 {
|
|
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=
|
|
287
|
-
{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
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 '—'
|
package/src/styles/terminal.css
CHANGED
|
@@ -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
|
|
563
|
-
*
|
|
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
|