@workerdeck/ui 0.22.0 → 1.0.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.
Files changed (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,28 +1,12 @@
1
- import {
2
- useImperativeHandle,
3
- useMemo,
4
- useRef,
5
- useState,
6
- type DragEvent,
7
- type ReactNode,
8
- type Ref,
9
- } from 'react'
1
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, type ReactNode, type Ref } from 'react'
10
2
  import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
11
3
  import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
12
- import {
13
- ArrowUp,
14
- FileText,
15
- Paperclip,
16
- RotateCw,
17
- Sparkles,
18
- Square,
19
- TriangleAlert,
20
- X,
21
- } from 'lucide-react'
4
+ import { ArrowUp, FileText, Paperclip, RotateCw, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
22
5
  import { Button } from '../ui/Button.tsx'
23
6
  import { Spinner } from '../ui/Spinner.tsx'
24
7
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
25
8
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
9
+ import { plainTextToSegments } from '../prompt-area/prompt-area-engine.ts'
26
10
  import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
27
11
  import { useTranscriptVariant } from './transcript-variant.tsx'
28
12
  import type { TerminalAffordances } from '../terminal/affordances.tsx'
@@ -32,123 +16,55 @@ import type { TriggerSuggestion } from '../prompt-area/types.ts'
32
16
  import { cn } from '../../lib/utils.ts'
33
17
  import { formatBytes } from '../../lib/format.ts'
34
18
 
35
- /** Files matching an `@` query, for the composer's file trigger. Structural so
36
- * the ui package doesn't have to reach for the protocol's `HostFileMatch`. */
37
19
  export type ComposerFileMatch = { path: string; relative: string }
38
20
 
39
- /** Imperative surface for panels that draft a message the user then finishes —
40
- * the skills dialog's "Use this skill". Nothing here sends. */
41
21
  export type ComposerHandle = {
42
- /** Append plain text at the caret's end and focus, separating it from
43
- * whatever is already there. */
44
22
  insertText: (text: string) => void
45
- /** Put the caret in the field, changing nothing. */
46
23
  focus: () => void
47
24
  }
48
25
 
49
26
  export interface ComposerProps {
50
- /** `attachmentIds` are the staged uploads, in the order they were picked. */
51
27
  onSend: (text: string, attachmentIds: string[]) => void
52
28
  onInterrupt: () => void
53
29
  busy: boolean
54
- /** Disable input entirely (session failed/closed). */
55
30
  disabled?: boolean
56
31
  placeholder?: string
57
- /** Slash commands offered as autocomplete; picked ones render as chips. */
58
32
  commands?: SlashCommandInfo[]
59
- /**
60
- * Skills offered under a **`$`** popover of their own — codex's sigil, kept
61
- * separate from `/` because the two behave differently. A skill is a typing
62
- * aid, not a command: picking one inserts editable text (the skill's own
63
- * `defaultPrompt` where it has one, else `$name`) and nothing is sent. No
64
- * engine parses `$skillname` as syntax, which is exactly why these can never
65
- * resolve to a chip the way `commands` do.
66
- */
67
33
  skills?: SkillInfo[]
68
- /** Host-file search behind the `@` trigger. Omit to leave `@` inert — a
69
- * gateway without host files has nothing to complete. */
70
34
  onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
71
- /** Attachment staging (see `useAttachments`). Omit for a text-only composer. */
72
35
  attachments?: UseAttachmentsResult
73
- /** Left side of the toolbar row (mode selects, …). */
74
36
  toolbar?: ReactNode
75
- /**
76
- * `'stacked'` (default) gives the buttons a row of their own under the field —
77
- * right where a toolbar belongs. `'inline'` puts the field and the buttons on
78
- * ONE line, growing from a single row as the message does: for a host whose
79
- * session controls live in its own chrome (VS Code's status bar), where the
80
- * empty composer would otherwise spend two rows saying nothing.
81
- */
82
37
  layout?: 'stacked' | 'inline'
83
- /**
84
- * Terminal theme only: the cell the composer draws on. Passed straight
85
- * through to its own {@link TerminalSurface}, and it has to be passed — the
86
- * composer sits outside the transcript's scroller, so it establishes a second
87
- * surface, and a surface handed no metrics falls back to the 13/18 default. A
88
- * host running the transcript at its editor's size and the composer at the
89
- * default would have the caret land on a different column from the text above
90
- * it, which is the one thing this theme exists to prevent.
91
- */
92
38
  fontSize?: number
93
39
  lineHeight?: number
94
- /** Terminal theme only; see {@link TerminalAffordances}. */
95
40
  affordances?: TerminalAffordances | boolean
41
+ /**
42
+ * Where unsent text is remembered between mounts. The composer is remounted on every session switch, so without
43
+ * this a half-written prompt dies with the switch. Omit to keep the composer stateless across mounts.
44
+ */
45
+ draft?: { initialText: string; save: (text: string) => void; clear: () => void }
96
46
  className?: string
97
47
  ref?: Ref<ComposerHandle>
98
48
  }
99
49
 
100
- /** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
101
- const cleanName = (name: string) => name.replace(/\s*\(MCP\)$/i, '')
50
+ function cleanName(name: string) {
51
+ return name.replace(/\s*\(MCP\)$/i, '')
52
+ }
102
53
 
103
- /**
104
- * What a picked skill types into the composer.
105
- *
106
- * The engine's own `defaultPrompt` when it declared one — it knows what its
107
- * skill wants to be asked — and otherwise `$name`, which is codex's native way
108
- * of referring to a skill in prompt text: its `skill-creator` documents the form
109
- * (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
110
- * prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
111
- * spells it beats paraphrasing into "Use the X skill to".
112
- *
113
- * Either way it ends in a space so the caret lands ready for the rest of the
114
- * sentence, and either way it is ordinary text: nothing here is submitted, and
115
- * nothing is parsed back out.
116
- */
117
54
  export function skillPrompt(skill: SkillInfo): string {
118
55
  const base = skill.defaultPrompt?.trim() || `$${skill.name}`
119
56
  return /\s$/.test(base) ? base : base + ' '
120
57
  }
121
58
 
122
- /** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
123
- * substring, 0 for no match. Shared so commands and skills sort as one list
124
- * rather than two concatenated ones. */
125
59
  function matchScore(query: string, haystacks: string[]): number {
126
60
  const needle = query.toLowerCase()
127
61
  const lowered = haystacks.map((s) => s.toLowerCase())
128
- if (lowered.some((h) => h.startsWith(needle))) return 2
62
+ if (lowered.some((h) => h.startsWith(needle))) {
63
+ return 2
64
+ }
129
65
  return lowered.some((h) => h.includes(needle)) ? 1 : 0
130
66
  }
131
67
 
132
- /**
133
- * Framed prompt input built on prompt-area's contentEditable.
134
- *
135
- * Three completions ride the same field and behave nothing alike. `/` is the
136
- * CLI's command list and `$` is the engine's skill list — both local, so they
137
- * filter completely and instantly; `@` is a search against the host filesystem,
138
- * debounced and abortable so a fast typist makes one request rather than eight.
139
- *
140
- * `/` and `$` are separate keys rather than one merged menu, and that mirrors
141
- * the engines themselves: codex completes skills on `$` and reserves `/` for
142
- * commands. The behaviours differ too — a command resolves to a **chip**,
143
- * because the CLI really does parse `/name` out of the message, while a skill
144
- * resolves to plain editable **text**, because no engine parses `$name` as
145
- * syntax; it is prose the model reads. Rendering them alike would promise
146
- * something that does not happen.
147
- *
148
- * Files can arrive three ways — the paperclip, a drop, or a paste — because on a
149
- * desktop all three are things people already do, and the upload starts the
150
- * moment one lands rather than at send time.
151
- */
152
68
  export function Composer({
153
69
  onSend,
154
70
  onInterrupt,
@@ -164,14 +80,17 @@ export function Composer({
164
80
  fontSize,
165
81
  lineHeight,
166
82
  affordances,
83
+ draft,
167
84
  className,
168
85
  ref,
169
86
  }: ComposerProps) {
170
87
  const inline = layout === 'inline'
171
- // The transcript's variant reaches here through the panel-wide context, so
172
- // the composer matches the rows above it without a prop chain.
173
88
  const terminal = useTranscriptVariant() === 'terminal'
174
- const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
89
+ // Read once, on mount: re-seeding mid-session would fight whatever is being typed.
90
+ const [initialDraft] = useState(() => draft?.initialText ?? '')
91
+ const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState(
92
+ initialDraft === '' ? undefined : { initialValue: plainTextToSegments(initialDraft) },
93
+ )
175
94
  const fileInput = useRef<HTMLInputElement>(null)
176
95
  const [dragging, setDragging] = useState(false)
177
96
 
@@ -179,8 +98,6 @@ export function Composer({
179
98
  ref,
180
99
  () => ({
181
100
  insertText: (text: string) => {
182
- // A space in front only when there is something to separate from, so a
183
- // draft into an empty composer doesn't start with one.
184
101
  const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? ' ' : ''
185
102
  bind.ref.current?.appendText(prefix + text)
186
103
  focus()
@@ -194,11 +111,12 @@ export function Composer({
194
111
  const configured = []
195
112
  const usableSkills = (skills ?? []).filter((s) => s.enabled)
196
113
  if (commands && commands.length > 0) {
197
- // The CLI list can contain the same skill name from several sources — first wins.
198
114
  const seen = new Set<string>()
199
115
  const unique = commands.flatMap((c) => {
200
116
  const name = cleanName(c.name)
201
- if (seen.has(name)) return []
117
+ if (seen.has(name)) {
118
+ return []
119
+ }
202
120
  seen.add(name)
203
121
  return [{ ...c, name }]
204
122
  })
@@ -207,10 +125,10 @@ export function Composer({
207
125
  onSearch: (query: string): TriggerSuggestion[] => {
208
126
  const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
209
127
  for (const c of unique) {
210
- // "wrapup" should find "dev:wrapup" — the bare half of a
211
- // namespaced name is what people type.
212
128
  const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
213
- if (score === 0) continue
129
+ if (score === 0) {
130
+ continue
131
+ }
214
132
  scored.push({
215
133
  score,
216
134
  suggestion: {
@@ -223,19 +141,12 @@ export function Composer({
223
141
  scored.sort((a, b) => b.score - a.score)
224
142
  return scored.map(({ suggestion }) => suggestion)
225
143
  },
226
- // Chip text renders as trigger + displayText — return the bare name so
227
- // the chip reads "/name" (label carries the argument hint for the menu).
228
144
  onSelect: (suggestion) => suggestion.value,
229
145
  chipClassName: 'font-mono',
230
146
  }),
231
147
  )
232
148
  }
233
149
  if (usableSkills.length > 0) {
234
- // `$`, not `/`, because that is codex's own sigil — its TUI completes
235
- // skills on `$` and reserves `/` for commands, and its bundled prompts
236
- // refer to skills that way in prose ("Use $pdf to …"). Matching it means
237
- // muscle memory transfers, and it keeps the two lists from being one
238
- // ambiguous menu of things that behave differently.
239
150
  configured.push(
240
151
  commandTrigger({
241
152
  char: '$',
@@ -244,27 +155,23 @@ export function Composer({
244
155
  const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
245
156
  for (const skill of usableSkills) {
246
157
  const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
247
- if (score === 0) continue
158
+ if (score === 0) {
159
+ continue
160
+ }
248
161
  const summary = skill.shortDescription ?? skill.description
249
162
  scored.push({
250
163
  score,
251
164
  suggestion: {
252
165
  value: skill.name,
253
166
  label: skill.displayName ?? skill.name,
254
- description: summary
255
- ? `Skill · ${summary}`
256
- : 'Skill · inserts a message you can edit',
257
- icon: <Sparkles className='size-3.5 text-fg-3' />,
167
+ description: summary ? `Skill · ${summary}` : 'Skill · inserts a message you can edit',
168
+ icon: <Sparkles className="size-3.5 text-fg-3" />,
258
169
  },
259
170
  })
260
171
  }
261
172
  scored.sort((a, b) => b.score - a.score)
262
173
  return scored.map(({ suggestion }) => suggestion)
263
174
  },
264
- // Always text, never a chip: a skill is not wire syntax the engine
265
- // parses back out, so what lands has to stay ordinary editable prose.
266
- // Returning a string unconditionally is what makes this trigger's
267
- // whole list behave that way.
268
175
  insertAsText: (suggestion) => {
269
176
  const skill = usableSkills.find((s) => s.name === suggestion.value)
270
177
  return skill ? skillPrompt(skill) : `$${suggestion.value} `
@@ -275,8 +182,6 @@ export function Composer({
275
182
  if (onSearchFiles) {
276
183
  configured.push(
277
184
  mentionTrigger({
278
- // A round trip per keystroke would be eight requests for one word; the
279
- // route is cheap but not free.
280
185
  searchDebounceMs: 150,
281
186
  onSearch: async (query, options) => {
282
187
  const matches = await onSearchFiles(query, options)
@@ -296,39 +201,39 @@ export function Composer({
296
201
  return configured.length > 0 ? configured : undefined
297
202
  }, [commands, skills, onSearchFiles])
298
203
 
204
+ const saveDraft = draft?.save
205
+ useEffect(() => {
206
+ saveDraft?.(plainText)
207
+ }, [plainText, saveDraft])
208
+
299
209
  const staged = attachments?.items ?? []
300
- // A photo on its own is a message send doesn't wait for text. It does wait
301
- // for the upload, since an id that hasn't landed can't be named.
302
- const canSend =
303
- !disabled &&
304
- (!isEmpty || staged.length > 0) &&
305
- !attachments?.uploading &&
306
- !attachments?.hasFailure
210
+ const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure
307
211
 
308
212
  const submit = () => {
309
- if (!canSend) return
213
+ if (!canSend) {
214
+ return
215
+ }
310
216
  onSend(plainText.trim(), attachments?.readyIds ?? [])
311
217
  attachments?.clear()
218
+ draft?.clear()
312
219
  clear()
313
220
  focus()
314
221
  }
315
222
 
316
223
  const pick = (files: FileList | null) => {
317
- if (files && files.length > 0) attachments?.add(files)
224
+ if (files && files.length > 0) {
225
+ attachments?.add(files)
226
+ }
318
227
  }
319
228
 
320
- // Built once, placed twice: the stacked layout gives these a toolbar row, the
321
- // inline one sets them either side of the field. An attach affordance the
322
- // engine has no meaning for is not a choice — the capability record decides it
323
- // exists.
324
229
  const fileField =
325
230
  attachments && !attachments.disabled ? (
326
231
  <input
327
232
  ref={fileInput}
328
- type='file'
233
+ type="file"
329
234
  multiple
330
235
  accept={attachments.accept || undefined}
331
- className='hidden'
236
+ className="hidden"
332
237
  onChange={(e) => {
333
238
  pick(e.target.files)
334
239
  // Re-picking the same file must fire `change` again.
@@ -341,87 +246,38 @@ export function Composer({
341
246
  const attach = canAttach ? (
342
247
  <>
343
248
  {fileField}
344
- <Button
345
- variant='ghost'
346
- size='icon-sm'
347
- aria-label='Attach files'
348
- disabled={disabled}
349
- onClick={() => fileInput.current?.click()}>
350
- <Paperclip className='size-4' />
249
+ <Button variant="ghost" size="icon-sm" aria-label="Attach files" disabled={disabled} onClick={() => fileInput.current?.click()}>
250
+ <Paperclip className="size-4" />
351
251
  </Button>
352
252
  </>
353
253
  ) : null
354
254
 
355
- /**
356
- * The terminal composer's **gutter cell** — the one column every transcript
357
- * row's marker sits in, so whatever stands here cannot move the text beside
358
- * it. It holds one of three things, in this order:
359
- *
360
- * `✕` **while the session is working**, because the gutter is where the eye
361
- * already is (it is the column the pulse and every marker share) and stop is
362
- * the only action that matters mid-run. Note the condition is `busy` alone,
363
- * not the old `busy && !canSend`: with send living on the other side of the
364
- * field there is no longer a slot to compete for, so typing a follow-up while
365
- * a turn runs no longer hides the way to stop it.
366
- *
367
- * A cross rather than the `■` this started as: the square reads as a *state*
368
- * ("stopped") in a column where `●` and `◆` really are states, so it looked
369
- * like a status marker rather than something to press. A cross reads as an
370
- * action and collides with nothing else on the column. It is also one of the
371
- * few candidates that measures exactly 1ch in JetBrains Mono — `⏹`, `⏸` and
372
- * `⏻`, the obvious picks, are 1.05–1.31 cells and would break the grid.
373
- *
374
- * `+` **otherwise**, when there is anything to attach — the composer's own
375
- * affordance, in the composer's own gutter.
376
- *
377
- * `❯` when neither applies, so the column is never empty and the typed line
378
- * never shifts. Blue, not the brand's coral: coral is the *working* mark, and
379
- * a prompt waiting for you is not the session working.
380
- */
381
255
  const gutter = busy ? (
382
- <GlyphButton gutter label='Interrupt' tone='yellow' onClick={onInterrupt}>
256
+ <GlyphButton gutter label="Interrupt" tone="yellow" onClick={onInterrupt}>
383
257
 
384
258
  </GlyphButton>
385
259
  ) : canAttach ? (
386
- <GlyphButton
387
- gutter
388
- label='Attach files'
389
- disabled={disabled}
390
- onClick={() => fileInput.current?.click()}>
260
+ <GlyphButton gutter label="Attach files" disabled={disabled} onClick={() => fileInput.current?.click()}>
391
261
  +
392
262
  </GlyphButton>
393
263
  ) : (
394
- <span aria-hidden className='term-gutter' data-tone='blue'>
264
+ <span aria-hidden className="term-gutter" data-tone="blue">
395
265
  {PROMPT_GLYPH}
396
266
  </span>
397
267
  )
398
268
 
399
269
  const interrupting = busy && !canSend
400
270
  const submitButton = terminal ? (
401
- // Terminal furniture rather than chat furniture: a glyph that lights up on
402
- // hover/focus instead of a filled pill. `↵` is the only thing on this side
403
- // now — stop moved to the gutter — so it means one thing at all times, and
404
- // a reader never has to check which symbol is currently under their cursor.
405
- <GlyphButton label='Send' disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
271
+ <GlyphButton label="Send" disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
406
272
 
407
273
  </GlyphButton>
408
274
  ) : interrupting ? (
409
- <Button
410
- variant='outline'
411
- size='icon-sm'
412
- aria-label='Interrupt'
413
- className='rounded-full'
414
- onClick={onInterrupt}>
415
- <Square className='size-3' />
275
+ <Button variant="outline" size="icon-sm" aria-label="Interrupt" className="rounded-full" onClick={onInterrupt}>
276
+ <Square className="size-3" />
416
277
  </Button>
417
278
  ) : (
418
- <Button
419
- size='icon-sm'
420
- aria-label='Send'
421
- className='rounded-full'
422
- disabled={!canSend}
423
- onClick={submit}>
424
- <ArrowUp className='size-4' />
279
+ <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={submit}>
280
+ <ArrowUp className="size-4" />
425
281
  </Button>
426
282
  )
427
283
 
@@ -434,7 +290,9 @@ export function Composer({
434
290
  },
435
291
  onDragLeave: () => setDragging(false),
436
292
  onDrop: (e: DragEvent) => {
437
- if (!attachments || attachments.disabled) return
293
+ if (!attachments || attachments.disabled) {
294
+ return
295
+ }
438
296
  e.preventDefault()
439
297
  setDragging(false)
440
298
  pick(e.dataTransfer.files)
@@ -442,84 +300,55 @@ export function Composer({
442
300
  }
443
301
 
444
302
  const errorRow = attachments?.error ? (
445
- <div
446
- className={cn(
447
- 'mx-auto mt-1 flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-danger',
448
- )}>
449
- <TriangleAlert className='size-3 shrink-0' />
450
- <span className='min-w-0 flex-1'>{attachments.error}</span>
451
- <button
452
- type='button'
453
- onClick={attachments.dismissError}
454
- aria-label='Dismiss'
455
- className='shrink-0 opacity-70 hover:opacity-100'>
456
- <X className='size-3' />
303
+ <div className={cn('mx-auto mt-1 flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-danger')}>
304
+ <TriangleAlert className="size-3 shrink-0" />
305
+ <span className="min-w-0 flex-1">{attachments.error}</span>
306
+ <button type="button" onClick={attachments.dismissError} aria-label="Dismiss" className="shrink-0 opacity-70 hover:opacity-100">
307
+ <X className="size-3" />
457
308
  </button>
458
309
  </div>
459
310
  ) : null
460
311
 
461
312
  if (terminal) {
462
- // The CLI's own prompt, on the CLI's own grid: `>` sits in the gutter cell
463
- // every transcript row's marker sits in, so what you type starts on the
464
- // column what you are reading starts on. That alignment is the entire
465
- // reason this is its own branch rather than the docked chrome restyled —
466
- // the composer is outside the transcript's scroller, so it needs a surface
467
- // of its own, at the same metrics.
468
313
  const line = lineHeight ?? 18
469
314
  return (
470
- <div data-slot='composer' className={cn('shrink-0', className)}>
315
+ <div data-slot="composer" className={cn('shrink-0', className)}>
471
316
  <TerminalSurface
472
317
  {...dropHandlers}
473
318
  fontSize={fontSize}
474
319
  lineHeight={lineHeight}
475
320
  affordances={affordances}
476
- bleed='1ch'
321
+ bleed="1ch"
477
322
  data-dragging={dragging || undefined}
478
- className={cn('term-composer', disabled && 'opacity-60')}>
479
- <div className='term-composer-body'>
480
- {staged.length > 0 && attachments ? (
481
- <AttachmentStrip attachments={attachments} />
482
- ) : null}
483
- <div className='term-row'>
484
- {/* The hidden input only — its trigger lives in the gutter. */}
323
+ className={cn('term-composer', disabled && 'opacity-60')}
324
+ >
325
+ <div className="term-composer-body">
326
+ {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
327
+ <div className="term-row">
485
328
  {canAttach ? fileField : null}
486
329
  {gutter}
487
- <div className='flex min-w-0 items-start'>
330
+ <div className="flex min-w-0 items-start">
488
331
  <PromptArea
489
332
  {...bind}
490
333
  triggers={triggers}
491
- // What the agent receives is markdown, so the marker the user
492
- // typed is the marker that has to survive. Left on (the
493
- // default), the editor rewrites `- ` to `• ` in the *model*,
494
- // not just on screen — so a bulleted message reached the agent
495
- // as `• item`, which is not a list in any markdown parser, and
496
- // drew a glyph the character grid has no cell for. Off, the
497
- // convenience stays and only the rewrite goes:
498
- // `insertListContinuation` keys on `[•\-*] ` and reuses the
499
- // line's own marker, so Enter after `- item` still inserts
500
- // `\n- `, and Enter on an empty item still leaves the list.
334
+ markdown={false}
501
335
  normalizeBullets={false}
502
336
  onSubmit={submit}
503
337
  disabled={disabled}
504
338
  placeholder={disabled ? 'Session ended' : placeholder}
505
- // The field's own metrics are the cell's: one row is one line,
506
- // and it grows in whole lines from there.
507
339
  minHeight={line}
508
340
  maxHeight={line * 10}
509
- aria-label='Message the agent'
510
- className='term-composer-field min-w-0 flex-1'
341
+ aria-label="Message the agent"
342
+ className="term-composer-field min-w-0 flex-1"
511
343
  onImagePaste={(file) => attachments?.add([file])}
512
344
  />
513
345
  {submitButton}
514
346
  </div>
515
347
  </div>
516
- {/* Model and mode below the prompt, on the body column so they read
517
- as a continuation of the line rather than new chrome. Absent
518
- under `controlsSurface: 'external'`. */}
519
348
  {toolbar ? (
520
- <div className='term-row'>
521
- <span aria-hidden className='term-gutter' />
522
- <div className='flex min-w-0 items-center gap-[1ch]'>{toolbar}</div>
349
+ <div className="term-row">
350
+ <span aria-hidden className="term-gutter" />
351
+ <div className="flex min-w-0 items-center gap-[1ch]">{toolbar}</div>
523
352
  </div>
524
353
  ) : null}
525
354
  </div>
@@ -530,7 +359,7 @@ export function Composer({
530
359
  }
531
360
 
532
361
  return (
533
- <div data-slot='composer' className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
362
+ <div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
534
363
  <div
535
364
  {...dropHandlers}
536
365
  className={cn(
@@ -539,32 +368,24 @@ export function Composer({
539
368
  'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
540
369
  dragging && 'border-ring ring-2 ring-ring/30',
541
370
  disabled && 'opacity-60',
542
- )}>
543
- {/* Above the field, like the picture you are talking about should be. */}
544
- {staged.length > 0 && attachments ? (
545
- <AttachmentStrip attachments={attachments} />
546
- ) : null}
371
+ )}
372
+ >
373
+ {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
547
374
  {inline ? (
548
- // One row until the message needs more: the field grows and the
549
- // buttons stay bottom-aligned as it does (`items-end`). On a single
550
- // line, the 28px buttons and the 20px line-height text need matching
551
- // vertical padding so their visual centres land on the same line:
552
- // 4px above + 4px below the 20px text = 28px, the button height.
553
- <div className='flex items-end gap-1 p-1'>
375
+ <div className="flex items-end gap-1 p-1">
554
376
  {attach}
555
377
  <PromptArea
556
378
  {...bind}
557
379
  triggers={triggers}
558
- // See the terminal composer above: `•` is not a markdown list
559
- // marker, and this rewrite reaches the sent message.
380
+ markdown={false}
560
381
  normalizeBullets={false}
561
382
  onSubmit={submit}
562
383
  disabled={disabled}
563
384
  placeholder={disabled ? 'Session ended' : placeholder}
564
385
  minHeight={20}
565
386
  maxHeight={192}
566
- aria-label='Message the agent'
567
- className='min-w-0 flex-1 py-1 text-body-sm text-text'
387
+ aria-label="Message the agent"
388
+ className="min-w-0 flex-1 py-1 text-body-sm text-text"
568
389
  onImagePaste={(file) => attachments?.add([file])}
569
390
  />
570
391
  {submitButton}
@@ -574,20 +395,19 @@ export function Composer({
574
395
  <PromptArea
575
396
  {...bind}
576
397
  triggers={triggers}
577
- // See the terminal composer above: `•` is not a markdown list
578
- // marker, and this rewrite reaches the sent message.
398
+ markdown={false}
579
399
  normalizeBullets={false}
580
400
  onSubmit={submit}
581
401
  disabled={disabled}
582
402
  placeholder={disabled ? 'Session ended' : placeholder}
583
403
  minHeight={28}
584
404
  maxHeight={192}
585
- aria-label='Message the agent'
586
- className='px-3 pt-2.5 pb-0 text-body-sm text-text'
405
+ aria-label="Message the agent"
406
+ className="px-3 pt-2.5 pb-0 text-body-sm text-text"
587
407
  onImagePaste={(file) => attachments?.add([file])}
588
408
  />
589
- <div className='flex items-center justify-between gap-2 px-2 pb-2'>
590
- <div className='flex min-w-0 items-center gap-1'>
409
+ <div className="flex items-center justify-between gap-2 px-2 pb-2">
410
+ <div className="flex min-w-0 items-center gap-1">
591
411
  {attach}
592
412
  {toolbar}
593
413
  </div>
@@ -601,14 +421,6 @@ export function Composer({
601
421
  )
602
422
  }
603
423
 
604
- /**
605
- * A composer action as a **character**, for the terminal composer: no pill, no
606
- * border, nothing drawn until you reach for it — the surface only appears on
607
- * hover/focus, which is how a terminal's own affordances behave.
608
- *
609
- * One cell wide and one line tall (`term-glyph` in `terminal.css`), so it sits
610
- * on the same grid as the row it shares and removing it would move nothing.
611
- */
612
424
  function GlyphButton({
613
425
  tone,
614
426
  label,
@@ -622,42 +434,30 @@ function GlyphButton({
622
434
  label: string
623
435
  disabled?: boolean
624
436
  onClick: () => void
625
- /** Standing in the gutter cell rather than at the row's trailing edge, so the
626
- * glyph aligns to the column's start like every transcript marker instead of
627
- * centring in the 2ch cell — see `.term-glyph[data-gutter]`. */
628
437
  gutter?: boolean
629
438
  className?: string
630
439
  children: ReactNode
631
440
  }) {
632
441
  return (
633
442
  <button
634
- type='button'
443
+ type="button"
635
444
  aria-label={label}
636
445
  title={label}
637
446
  disabled={disabled}
638
447
  onClick={onClick}
639
448
  data-tone={tone}
640
449
  data-gutter={gutter || undefined}
641
- className={cn('term-glyph', className)}>
450
+ className={cn('term-glyph', className)}
451
+ >
642
452
  {children}
643
453
  </button>
644
454
  )
645
455
  }
646
456
 
647
- /** Staged files as a scrolling row of chips above the field. The thumbnail is
648
- * the local blob, so nothing here waits on the network; the upload's state rides
649
- * on top of it and the ✕ takes it back off. */
650
457
  function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
651
458
  const terminal = useTranscriptVariant() === 'terminal'
652
459
  return (
653
- <div
654
- className={cn(
655
- 'flex gap-2 overflow-x-auto',
656
- // The terminal form draws no rule of its own: the composer's frame is
657
- // already directly below this strip, and two rules a few pixels apart
658
- // is the box the theme has none of. Geometry in `terminal.css`.
659
- terminal ? 'term-attachments' : 'border-b border-border px-2 py-2',
660
- )}>
460
+ <div className={cn('flex gap-2 overflow-x-auto', terminal ? 'term-attachments' : 'border-b border-border px-2 py-2')}>
661
461
  {attachments.items.map((item) => (
662
462
  <AttachmentChip
663
463
  key={item.key}
@@ -670,25 +470,12 @@ function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult })
670
470
  )
671
471
  }
672
472
 
673
- function AttachmentChip({
674
- item,
675
- onRetry,
676
- onRemove,
677
- }: {
678
- item: StagedAttachment
679
- onRetry: () => void
680
- onRemove: () => void
681
- }) {
473
+ function AttachmentChip({ item, onRetry, onRemove }: { item: StagedAttachment; onRetry: () => void; onRemove: () => void }) {
682
474
  const failed = item.status === 'failed'
683
475
  const terminal = useTranscriptVariant() === 'terminal'
684
- // Corners are the whole difference: rounded and floating in `cards`, square
685
- // and inside the frame here. Kept as one flag rather than four so a thumbnail
686
- // and the overlays stacked on it cannot disagree about their own shape.
687
476
  const round = terminal ? '' : 'rounded-md'
688
477
  return (
689
- <div
690
- className='group relative shrink-0'
691
- title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
478
+ <div className="group relative shrink-0" title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
692
479
  <div
693
480
  className={cn(
694
481
  'flex size-14 items-center justify-center overflow-hidden bg-surface',
@@ -696,54 +483,48 @@ function AttachmentChip({
696
483
  terminal ? 'term-attachment' : 'border border-border',
697
484
  failed && 'border-danger/50',
698
485
  )}
699
- data-failed={terminal && failed ? '' : undefined}>
486
+ data-failed={terminal && failed ? '' : undefined}
487
+ >
700
488
  {item.previewUrl ? (
701
- <img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
489
+ <img src={item.previewUrl} alt={item.name} className="size-full object-cover" />
702
490
  ) : (
703
- <div className='flex flex-col items-center gap-0.5 text-fg-3'>
704
- <FileText className='size-4' />
705
- <span className='max-w-12 truncate text-[9px] font-semibold uppercase'>
706
- {extensionOf(item.name)}
707
- </span>
491
+ <div className="flex flex-col items-center gap-0.5 text-fg-3">
492
+ <FileText className="size-4" />
493
+ <span className="max-w-12 truncate text-[9px] font-semibold uppercase">{extensionOf(item.name)}</span>
708
494
  </div>
709
495
  )}
710
496
  </div>
711
497
  {item.status === 'uploading' ? (
712
498
  <div className={cn('absolute inset-0 flex items-center justify-center bg-black/35', round)}>
713
- <Spinner className='size-4 text-white' />
499
+ <Spinner className="size-4 text-white" />
714
500
  </div>
715
501
  ) : null}
716
502
  {failed ? (
717
503
  <button
718
- type='button'
504
+ type="button"
719
505
  onClick={onRetry}
720
506
  aria-label={`Retry ${item.name}`}
721
- className={cn(
722
- 'absolute inset-0 flex items-center justify-center bg-black/45 text-warning',
723
- round,
724
- )}>
725
- <RotateCw className='size-4' />
507
+ className={cn('absolute inset-0 flex items-center justify-center bg-black/45 text-warning', round)}
508
+ >
509
+ <RotateCw className="size-4" />
726
510
  </button>
727
511
  ) : null}
728
512
  <button
729
- type='button'
513
+ type="button"
730
514
  onClick={onRemove}
731
515
  aria-label={`Remove ${item.name}`}
732
516
  className={cn(
733
517
  'absolute flex size-4 items-center justify-center bg-surface text-fg-3 hover:text-fg-1',
734
- // Cards hang it off the corner as a round badge; the terminal tucks it
735
- // inside, squared, with two rules for its corner (`terminal.css`).
736
- terminal
737
- ? 'term-attachment-remove'
738
- : '-top-1 -right-1 rounded-full border border-border shadow-(--shadow-xs)',
739
- )}>
740
- <X className='size-2.5' />
518
+ terminal ? 'term-attachment-remove' : '-top-1 -right-1 rounded-full border border-border shadow-(--shadow-xs)',
519
+ )}
520
+ >
521
+ <X className="size-2.5" />
741
522
  </button>
742
523
  </div>
743
524
  )
744
525
  }
745
526
 
746
- const extensionOf = (name: string) => {
527
+ function extensionOf(name: string) {
747
528
  const dot = name.lastIndexOf('.')
748
529
  return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
749
530
  }