@jonsoc/app 1.1.34

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 (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,2076 @@
1
+ import { useFilteredList } from "@jonsoc/ui/hooks"
2
+ import {
3
+ createEffect,
4
+ on,
5
+ Component,
6
+ Show,
7
+ For,
8
+ onMount,
9
+ onCleanup,
10
+ Switch,
11
+ Match,
12
+ createMemo,
13
+ createSignal,
14
+ } from "solid-js"
15
+ import { createStore, produce } from "solid-js/store"
16
+ import { createFocusSignal } from "@solid-primitives/active-element"
17
+ import { useLocal } from "@/context/local"
18
+ import { useFile, type FileSelection } from "@/context/file"
19
+ import {
20
+ ContentPart,
21
+ DEFAULT_PROMPT,
22
+ isPromptEqual,
23
+ Prompt,
24
+ usePrompt,
25
+ ImageAttachmentPart,
26
+ AgentPart,
27
+ FileAttachmentPart,
28
+ } from "@/context/prompt"
29
+ import { useLayout } from "@/context/layout"
30
+ import { useSDK } from "@/context/sdk"
31
+ import { useNavigate, useParams } from "@solidjs/router"
32
+ import { useSync } from "@/context/sync"
33
+ import { useComments } from "@/context/comments"
34
+ import { FileIcon } from "@jonsoc/ui/file-icon"
35
+ import { Button } from "@jonsoc/ui/button"
36
+ import { Icon } from "@jonsoc/ui/icon"
37
+ import { ProviderIcon } from "@jonsoc/ui/provider-icon"
38
+ import type { IconName } from "@jonsoc/ui/icons/provider"
39
+ import { Tooltip, TooltipKeybind } from "@jonsoc/ui/tooltip"
40
+ import { IconButton } from "@jonsoc/ui/icon-button"
41
+ import { Select } from "@jonsoc/ui/select"
42
+ import { getDirectory, getFilename } from "@jonsoc/util/path"
43
+ import { useDialog } from "@jonsoc/ui/context/dialog"
44
+ import { ImagePreview } from "@jonsoc/ui/image-preview"
45
+ import { ModelSelectorPopover } from "@/components/dialog-select-model"
46
+ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
47
+ import { useProviders } from "@/hooks/use-providers"
48
+ import { useCommand } from "@/context/command"
49
+ import { Persist, persisted } from "@/utils/persist"
50
+ import { Identifier } from "@/utils/id"
51
+ import { Worktree as WorktreeState } from "@/utils/worktree"
52
+ import { SessionContextUsage } from "@/components/session-context-usage"
53
+ import { usePermission } from "@/context/permission"
54
+ import { useLanguage } from "@/context/language"
55
+ import { useGlobalSync } from "@/context/global-sync"
56
+ import { usePlatform } from "@/context/platform"
57
+ import { createOpencodeClient, type Message, type Part } from "@jonsoc/sdk/v2/client"
58
+ import { Binary } from "@jonsoc/util/binary"
59
+ import { showToast } from "@jonsoc/ui/toast"
60
+ import { base64Encode } from "@jonsoc/util/encode"
61
+
62
+ const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
63
+ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
64
+
65
+ type PendingPrompt = {
66
+ abort: AbortController
67
+ cleanup: VoidFunction
68
+ }
69
+
70
+ const pending = new Map<string, PendingPrompt>()
71
+
72
+ interface PromptInputProps {
73
+ class?: string
74
+ ref?: (el: HTMLDivElement) => void
75
+ newSessionWorktree?: string
76
+ onNewSessionWorktreeReset?: () => void
77
+ onSubmit?: () => void
78
+ }
79
+
80
+ const EXAMPLES = [
81
+ "prompt.example.1",
82
+ "prompt.example.2",
83
+ "prompt.example.3",
84
+ "prompt.example.4",
85
+ "prompt.example.5",
86
+ "prompt.example.6",
87
+ "prompt.example.7",
88
+ "prompt.example.8",
89
+ "prompt.example.9",
90
+ "prompt.example.10",
91
+ "prompt.example.11",
92
+ "prompt.example.12",
93
+ "prompt.example.13",
94
+ "prompt.example.14",
95
+ "prompt.example.15",
96
+ "prompt.example.16",
97
+ "prompt.example.17",
98
+ "prompt.example.18",
99
+ "prompt.example.19",
100
+ "prompt.example.20",
101
+ "prompt.example.21",
102
+ "prompt.example.22",
103
+ "prompt.example.23",
104
+ "prompt.example.24",
105
+ "prompt.example.25",
106
+ ] as const
107
+
108
+ interface SlashCommand {
109
+ id: string
110
+ trigger: string
111
+ title: string
112
+ description?: string
113
+ keybind?: string
114
+ type: "builtin" | "custom"
115
+ }
116
+
117
+ export const PromptInput: Component<PromptInputProps> = (props) => {
118
+ const navigate = useNavigate()
119
+ const sdk = useSDK()
120
+ const sync = useSync()
121
+ const globalSync = useGlobalSync()
122
+ const platform = usePlatform()
123
+ const local = useLocal()
124
+ const files = useFile()
125
+ const prompt = usePrompt()
126
+ const layout = useLayout()
127
+ const comments = useComments()
128
+ const params = useParams()
129
+ const dialog = useDialog()
130
+ const providers = useProviders()
131
+ const command = useCommand()
132
+ const permission = usePermission()
133
+ const language = useLanguage()
134
+ let editorRef!: HTMLDivElement
135
+ let fileInputRef!: HTMLInputElement
136
+ let scrollRef!: HTMLDivElement
137
+ let slashPopoverRef!: HTMLDivElement
138
+
139
+ const scrollCursorIntoView = () => {
140
+ const container = scrollRef
141
+ const selection = window.getSelection()
142
+ if (!container || !selection || selection.rangeCount === 0) return
143
+
144
+ const range = selection.getRangeAt(0)
145
+ if (!editorRef.contains(range.startContainer)) return
146
+
147
+ const rect = range.getBoundingClientRect()
148
+ if (!rect.height) return
149
+
150
+ const containerRect = container.getBoundingClientRect()
151
+ const top = rect.top - containerRect.top + container.scrollTop
152
+ const bottom = rect.bottom - containerRect.top + container.scrollTop
153
+ const padding = 12
154
+
155
+ if (top < container.scrollTop + padding) {
156
+ container.scrollTop = Math.max(0, top - padding)
157
+ return
158
+ }
159
+
160
+ if (bottom > container.scrollTop + container.clientHeight - padding) {
161
+ container.scrollTop = bottom - container.clientHeight + padding
162
+ }
163
+ }
164
+
165
+ const queueScroll = () => {
166
+ requestAnimationFrame(scrollCursorIntoView)
167
+ }
168
+
169
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
170
+ const tabs = createMemo(() => layout.tabs(sessionKey))
171
+ const view = createMemo(() => layout.view(sessionKey))
172
+
173
+ const recent = createMemo(() => {
174
+ const all = tabs().all()
175
+ const active = tabs().active()
176
+ const order = active ? [active, ...all.filter((x) => x !== active)] : all
177
+ const seen = new Set<string>()
178
+ const paths: string[] = []
179
+
180
+ for (const tab of order) {
181
+ const path = files.pathFromTab(tab)
182
+ if (!path) continue
183
+ if (seen.has(path)) continue
184
+ seen.add(path)
185
+ paths.push(path)
186
+ }
187
+
188
+ return paths
189
+ })
190
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
191
+ const status = createMemo(
192
+ () =>
193
+ sync.data.session_status[params.id ?? ""] ?? {
194
+ type: "idle",
195
+ },
196
+ )
197
+ const working = createMemo(() => status()?.type !== "idle")
198
+ const imageAttachments = createMemo(
199
+ () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
200
+ )
201
+
202
+ const [store, setStore] = createStore<{
203
+ popover: "at" | "slash" | null
204
+ historyIndex: number
205
+ savedPrompt: Prompt | null
206
+ placeholder: number
207
+ dragging: boolean
208
+ mode: "normal" | "shell"
209
+ applyingHistory: boolean
210
+ }>({
211
+ popover: null,
212
+ historyIndex: -1,
213
+ savedPrompt: null,
214
+ placeholder: Math.floor(Math.random() * EXAMPLES.length),
215
+ dragging: false,
216
+ mode: "normal",
217
+ applyingHistory: false,
218
+ })
219
+
220
+ const MAX_HISTORY = 100
221
+ const [history, setHistory] = persisted(
222
+ Persist.global("prompt-history", ["prompt-history.v1"]),
223
+ createStore<{
224
+ entries: Prompt[]
225
+ }>({
226
+ entries: [],
227
+ }),
228
+ )
229
+ const [shellHistory, setShellHistory] = persisted(
230
+ Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
231
+ createStore<{
232
+ entries: Prompt[]
233
+ }>({
234
+ entries: [],
235
+ }),
236
+ )
237
+
238
+ const clonePromptParts = (prompt: Prompt): Prompt =>
239
+ prompt.map((part) => {
240
+ if (part.type === "text") return { ...part }
241
+ if (part.type === "image") return { ...part }
242
+ if (part.type === "agent") return { ...part }
243
+ return {
244
+ ...part,
245
+ selection: part.selection ? { ...part.selection } : undefined,
246
+ }
247
+ })
248
+
249
+ const promptLength = (prompt: Prompt) =>
250
+ prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
251
+
252
+ const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
253
+ const length = position === "start" ? 0 : promptLength(p)
254
+ setStore("applyingHistory", true)
255
+ prompt.set(p, length)
256
+ requestAnimationFrame(() => {
257
+ editorRef.focus()
258
+ setCursorPosition(editorRef, length)
259
+ setStore("applyingHistory", false)
260
+ queueScroll()
261
+ })
262
+ }
263
+
264
+ const getCaretState = () => {
265
+ const selection = window.getSelection()
266
+ const textLength = promptLength(prompt.current())
267
+ if (!selection || selection.rangeCount === 0) {
268
+ return { collapsed: false, cursorPosition: 0, textLength }
269
+ }
270
+ const anchorNode = selection.anchorNode
271
+ if (!anchorNode || !editorRef.contains(anchorNode)) {
272
+ return { collapsed: false, cursorPosition: 0, textLength }
273
+ }
274
+ return {
275
+ collapsed: selection.isCollapsed,
276
+ cursorPosition: getCursorPosition(editorRef),
277
+ textLength,
278
+ }
279
+ }
280
+
281
+ const isFocused = createFocusSignal(() => editorRef)
282
+
283
+ createEffect(() => {
284
+ params.id
285
+ if (params.id) return
286
+ const interval = setInterval(() => {
287
+ setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
288
+ }, 6500)
289
+ onCleanup(() => clearInterval(interval))
290
+ })
291
+
292
+ const [composing, setComposing] = createSignal(false)
293
+ const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
294
+
295
+ const addImageAttachment = async (file: File) => {
296
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
297
+
298
+ const reader = new FileReader()
299
+ reader.onload = () => {
300
+ const dataUrl = reader.result as string
301
+ const attachment: ImageAttachmentPart = {
302
+ type: "image",
303
+ id: crypto.randomUUID(),
304
+ filename: file.name,
305
+ mime: file.type,
306
+ dataUrl,
307
+ }
308
+ const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
309
+ prompt.set([...prompt.current(), attachment], cursorPosition)
310
+ }
311
+ reader.readAsDataURL(file)
312
+ }
313
+
314
+ const removeImageAttachment = (id: string) => {
315
+ const current = prompt.current()
316
+ const next = current.filter((part) => part.type !== "image" || part.id !== id)
317
+ prompt.set(next, prompt.cursor())
318
+ }
319
+
320
+ const handlePaste = async (event: ClipboardEvent) => {
321
+ if (!isFocused()) return
322
+ const clipboardData = event.clipboardData
323
+ if (!clipboardData) return
324
+
325
+ event.preventDefault()
326
+ event.stopPropagation()
327
+
328
+ const items = Array.from(clipboardData.items)
329
+ const fileItems = items.filter((item) => item.kind === "file")
330
+ const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
331
+
332
+ if (imageItems.length > 0) {
333
+ for (const item of imageItems) {
334
+ const file = item.getAsFile()
335
+ if (file) await addImageAttachment(file)
336
+ }
337
+ return
338
+ }
339
+
340
+ if (fileItems.length > 0) {
341
+ showToast({
342
+ title: language.t("prompt.toast.pasteUnsupported.title"),
343
+ description: language.t("prompt.toast.pasteUnsupported.description"),
344
+ })
345
+ return
346
+ }
347
+
348
+ const plainText = clipboardData.getData("text/plain") ?? ""
349
+ if (!plainText) return
350
+ addPart({ type: "text", content: plainText, start: 0, end: 0 })
351
+ }
352
+
353
+ const handleGlobalDragOver = (event: DragEvent) => {
354
+ if (dialog.active) return
355
+
356
+ event.preventDefault()
357
+ const hasFiles = event.dataTransfer?.types.includes("Files")
358
+ if (hasFiles) {
359
+ setStore("dragging", true)
360
+ }
361
+ }
362
+
363
+ const handleGlobalDragLeave = (event: DragEvent) => {
364
+ if (dialog.active) return
365
+
366
+ // relatedTarget is null when leaving the document window
367
+ if (!event.relatedTarget) {
368
+ setStore("dragging", false)
369
+ }
370
+ }
371
+
372
+ const handleGlobalDrop = async (event: DragEvent) => {
373
+ if (dialog.active) return
374
+
375
+ event.preventDefault()
376
+ setStore("dragging", false)
377
+
378
+ const dropped = event.dataTransfer?.files
379
+ if (!dropped) return
380
+
381
+ for (const file of Array.from(dropped)) {
382
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
383
+ await addImageAttachment(file)
384
+ }
385
+ }
386
+ }
387
+
388
+ onMount(() => {
389
+ document.addEventListener("dragover", handleGlobalDragOver)
390
+ document.addEventListener("dragleave", handleGlobalDragLeave)
391
+ document.addEventListener("drop", handleGlobalDrop)
392
+ })
393
+ onCleanup(() => {
394
+ document.removeEventListener("dragover", handleGlobalDragOver)
395
+ document.removeEventListener("dragleave", handleGlobalDragLeave)
396
+ document.removeEventListener("drop", handleGlobalDrop)
397
+ })
398
+
399
+ createEffect(() => {
400
+ if (!isFocused()) setStore("popover", null)
401
+ })
402
+
403
+ // Safety: reset composing state on focus change to prevent stuck state
404
+ // This handles edge cases where compositionend event may not fire
405
+ createEffect(() => {
406
+ if (!isFocused()) setComposing(false)
407
+ })
408
+
409
+ type AtOption =
410
+ | { type: "agent"; name: string; display: string }
411
+ | { type: "file"; path: string; display: string; recent?: boolean }
412
+
413
+ const agentList = createMemo(() =>
414
+ sync.data.agent
415
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
416
+ .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
417
+ )
418
+
419
+ const handleAtSelect = (option: AtOption | undefined) => {
420
+ if (!option) return
421
+ if (option.type === "agent") {
422
+ addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
423
+ } else {
424
+ addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
425
+ }
426
+ }
427
+
428
+ const atKey = (x: AtOption | undefined) => {
429
+ if (!x) return ""
430
+ return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
431
+ }
432
+
433
+ const {
434
+ flat: atFlat,
435
+ active: atActive,
436
+ setActive: setAtActive,
437
+ onInput: atOnInput,
438
+ onKeyDown: atOnKeyDown,
439
+ } = useFilteredList<AtOption>({
440
+ items: async (query) => {
441
+ const agents = agentList()
442
+ const open = recent()
443
+ const seen = new Set(open)
444
+ const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
445
+ const paths = await files.searchFilesAndDirectories(query)
446
+ const fileOptions: AtOption[] = paths
447
+ .filter((path) => !seen.has(path))
448
+ .map((path) => ({ type: "file", path, display: path }))
449
+ return [...agents, ...pinned, ...fileOptions]
450
+ },
451
+ key: atKey,
452
+ filterKeys: ["display"],
453
+ groupBy: (item) => {
454
+ if (item.type === "agent") return "agent"
455
+ if (item.recent) return "recent"
456
+ return "file"
457
+ },
458
+ sortGroupsBy: (a, b) => {
459
+ const rank = (category: string) => {
460
+ if (category === "agent") return 0
461
+ if (category === "recent") return 1
462
+ return 2
463
+ }
464
+ return rank(a.category) - rank(b.category)
465
+ },
466
+ onSelect: handleAtSelect,
467
+ })
468
+
469
+ const slashCommands = createMemo<SlashCommand[]>(() => {
470
+ const builtin = command.options
471
+ .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
472
+ .map((opt) => ({
473
+ id: opt.id,
474
+ trigger: opt.slash!,
475
+ title: opt.title,
476
+ description: opt.description,
477
+ keybind: opt.keybind,
478
+ type: "builtin" as const,
479
+ }))
480
+
481
+ const custom = sync.data.command.map((cmd) => ({
482
+ id: `custom.${cmd.name}`,
483
+ trigger: cmd.name,
484
+ title: cmd.name,
485
+ description: cmd.description,
486
+ type: "custom" as const,
487
+ }))
488
+
489
+ return [...custom, ...builtin]
490
+ })
491
+
492
+ const handleSlashSelect = (cmd: SlashCommand | undefined) => {
493
+ if (!cmd) return
494
+ setStore("popover", null)
495
+
496
+ if (cmd.type === "custom") {
497
+ const text = `/${cmd.trigger} `
498
+ editorRef.innerHTML = ""
499
+ editorRef.textContent = text
500
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
501
+ requestAnimationFrame(() => {
502
+ editorRef.focus()
503
+ const range = document.createRange()
504
+ const sel = window.getSelection()
505
+ range.selectNodeContents(editorRef)
506
+ range.collapse(false)
507
+ sel?.removeAllRanges()
508
+ sel?.addRange(range)
509
+ })
510
+ return
511
+ }
512
+
513
+ editorRef.innerHTML = ""
514
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
515
+ command.trigger(cmd.id, "slash")
516
+ }
517
+
518
+ const {
519
+ flat: slashFlat,
520
+ active: slashActive,
521
+ setActive: setSlashActive,
522
+ onInput: slashOnInput,
523
+ onKeyDown: slashOnKeyDown,
524
+ refetch: slashRefetch,
525
+ } = useFilteredList<SlashCommand>({
526
+ items: slashCommands,
527
+ key: (x) => x?.id,
528
+ filterKeys: ["trigger", "title", "description"],
529
+ onSelect: handleSlashSelect,
530
+ })
531
+
532
+ const createPill = (part: FileAttachmentPart | AgentPart) => {
533
+ const pill = document.createElement("span")
534
+ pill.textContent = part.content
535
+ pill.setAttribute("data-type", part.type)
536
+ if (part.type === "file") pill.setAttribute("data-path", part.path)
537
+ if (part.type === "agent") pill.setAttribute("data-name", part.name)
538
+ pill.setAttribute("contenteditable", "false")
539
+ pill.style.userSelect = "text"
540
+ pill.style.cursor = "default"
541
+ return pill
542
+ }
543
+
544
+ const isNormalizedEditor = () =>
545
+ Array.from(editorRef.childNodes).every((node) => {
546
+ if (node.nodeType === Node.TEXT_NODE) {
547
+ const text = node.textContent ?? ""
548
+ if (!text.includes("\u200B")) return true
549
+ if (text !== "\u200B") return false
550
+
551
+ const prev = node.previousSibling
552
+ const next = node.nextSibling
553
+ const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
554
+ const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
555
+ if (!prevIsBr && !nextIsBr) return false
556
+ if (nextIsBr && !prevIsBr && prev) return false
557
+ return true
558
+ }
559
+ if (node.nodeType !== Node.ELEMENT_NODE) return false
560
+ const el = node as HTMLElement
561
+ if (el.dataset.type === "file") return true
562
+ if (el.dataset.type === "agent") return true
563
+ return el.tagName === "BR"
564
+ })
565
+
566
+ const renderEditor = (parts: Prompt) => {
567
+ editorRef.innerHTML = ""
568
+ for (const part of parts) {
569
+ if (part.type === "text") {
570
+ editorRef.appendChild(createTextFragment(part.content))
571
+ continue
572
+ }
573
+ if (part.type === "file" || part.type === "agent") {
574
+ editorRef.appendChild(createPill(part))
575
+ }
576
+ }
577
+ }
578
+
579
+ createEffect(
580
+ on(
581
+ () => sync.data.command,
582
+ () => slashRefetch(),
583
+ { defer: true },
584
+ ),
585
+ )
586
+
587
+ // Auto-scroll active command into view when navigating with keyboard
588
+ createEffect(() => {
589
+ const activeId = slashActive()
590
+ if (!activeId || !slashPopoverRef) return
591
+
592
+ requestAnimationFrame(() => {
593
+ const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
594
+ element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
595
+ })
596
+ })
597
+
598
+ const selectPopoverActive = () => {
599
+ if (store.popover === "at") {
600
+ const items = atFlat()
601
+ if (items.length === 0) return
602
+ const active = atActive()
603
+ const item = items.find((entry) => atKey(entry) === active) ?? items[0]
604
+ handleAtSelect(item)
605
+ return
606
+ }
607
+
608
+ if (store.popover === "slash") {
609
+ const items = slashFlat()
610
+ if (items.length === 0) return
611
+ const active = slashActive()
612
+ const item = items.find((entry) => entry.id === active) ?? items[0]
613
+ handleSlashSelect(item)
614
+ }
615
+ }
616
+
617
+ createEffect(
618
+ on(
619
+ () => prompt.current(),
620
+ (currentParts) => {
621
+ const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
622
+ const domParts = parseFromDOM()
623
+ if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
624
+
625
+ const selection = window.getSelection()
626
+ let cursorPosition: number | null = null
627
+ if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
628
+ cursorPosition = getCursorPosition(editorRef)
629
+ }
630
+
631
+ renderEditor(inputParts)
632
+
633
+ if (cursorPosition !== null) {
634
+ setCursorPosition(editorRef, cursorPosition)
635
+ }
636
+ },
637
+ ),
638
+ )
639
+
640
+ const parseFromDOM = (): Prompt => {
641
+ const parts: Prompt = []
642
+ let position = 0
643
+ let buffer = ""
644
+
645
+ const flushText = () => {
646
+ const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
647
+ buffer = ""
648
+ if (!content) return
649
+ parts.push({ type: "text", content, start: position, end: position + content.length })
650
+ position += content.length
651
+ }
652
+
653
+ const pushFile = (file: HTMLElement) => {
654
+ const content = file.textContent ?? ""
655
+ parts.push({
656
+ type: "file",
657
+ path: file.dataset.path!,
658
+ content,
659
+ start: position,
660
+ end: position + content.length,
661
+ })
662
+ position += content.length
663
+ }
664
+
665
+ const pushAgent = (agent: HTMLElement) => {
666
+ const content = agent.textContent ?? ""
667
+ parts.push({
668
+ type: "agent",
669
+ name: agent.dataset.name!,
670
+ content,
671
+ start: position,
672
+ end: position + content.length,
673
+ })
674
+ position += content.length
675
+ }
676
+
677
+ const visit = (node: Node) => {
678
+ if (node.nodeType === Node.TEXT_NODE) {
679
+ buffer += node.textContent ?? ""
680
+ return
681
+ }
682
+ if (node.nodeType !== Node.ELEMENT_NODE) return
683
+
684
+ const el = node as HTMLElement
685
+ if (el.dataset.type === "file") {
686
+ flushText()
687
+ pushFile(el)
688
+ return
689
+ }
690
+ if (el.dataset.type === "agent") {
691
+ flushText()
692
+ pushAgent(el)
693
+ return
694
+ }
695
+ if (el.tagName === "BR") {
696
+ buffer += "\n"
697
+ return
698
+ }
699
+
700
+ for (const child of Array.from(el.childNodes)) {
701
+ visit(child)
702
+ }
703
+ }
704
+
705
+ const children = Array.from(editorRef.childNodes)
706
+ children.forEach((child, index) => {
707
+ const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
708
+ visit(child)
709
+ if (isBlock && index < children.length - 1) {
710
+ buffer += "\n"
711
+ }
712
+ })
713
+
714
+ flushText()
715
+
716
+ if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
717
+ return parts
718
+ }
719
+
720
+ const handleInput = () => {
721
+ const rawParts = parseFromDOM()
722
+ const images = imageAttachments()
723
+ const cursorPosition = getCursorPosition(editorRef)
724
+ const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
725
+ const trimmed = rawText.replace(/\u200B/g, "").trim()
726
+ const hasNonText = rawParts.some((part) => part.type !== "text")
727
+ const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
728
+
729
+ if (shouldReset) {
730
+ setStore("popover", null)
731
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
732
+ setStore("historyIndex", -1)
733
+ setStore("savedPrompt", null)
734
+ }
735
+ if (prompt.dirty()) {
736
+ prompt.set(DEFAULT_PROMPT, 0)
737
+ }
738
+ queueScroll()
739
+ return
740
+ }
741
+
742
+ const shellMode = store.mode === "shell"
743
+
744
+ if (!shellMode) {
745
+ const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
746
+ const slashMatch = rawText.match(/^\/(\S*)$/)
747
+
748
+ if (atMatch) {
749
+ atOnInput(atMatch[1])
750
+ setStore("popover", "at")
751
+ } else if (slashMatch) {
752
+ slashOnInput(slashMatch[1])
753
+ setStore("popover", "slash")
754
+ } else {
755
+ setStore("popover", null)
756
+ }
757
+ } else {
758
+ setStore("popover", null)
759
+ }
760
+
761
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
762
+ setStore("historyIndex", -1)
763
+ setStore("savedPrompt", null)
764
+ }
765
+
766
+ prompt.set([...rawParts, ...images], cursorPosition)
767
+ queueScroll()
768
+ }
769
+
770
+ const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
771
+ let remaining = offset
772
+ const nodes = Array.from(editorRef.childNodes)
773
+
774
+ for (const node of nodes) {
775
+ const length = getNodeLength(node)
776
+ const isText = node.nodeType === Node.TEXT_NODE
777
+ const isPill =
778
+ node.nodeType === Node.ELEMENT_NODE &&
779
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
780
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
781
+
782
+ if (isText && remaining <= length) {
783
+ if (edge === "start") range.setStart(node, remaining)
784
+ if (edge === "end") range.setEnd(node, remaining)
785
+ return
786
+ }
787
+
788
+ if ((isPill || isBreak) && remaining <= length) {
789
+ if (edge === "start" && remaining === 0) range.setStartBefore(node)
790
+ if (edge === "start" && remaining > 0) range.setStartAfter(node)
791
+ if (edge === "end" && remaining === 0) range.setEndBefore(node)
792
+ if (edge === "end" && remaining > 0) range.setEndAfter(node)
793
+ return
794
+ }
795
+
796
+ remaining -= length
797
+ }
798
+ }
799
+
800
+ const addPart = (part: ContentPart) => {
801
+ const selection = window.getSelection()
802
+ if (!selection || selection.rangeCount === 0) return
803
+
804
+ const cursorPosition = getCursorPosition(editorRef)
805
+ const currentPrompt = prompt.current()
806
+ const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
807
+ const textBeforeCursor = rawText.substring(0, cursorPosition)
808
+ const atMatch = textBeforeCursor.match(/@(\S*)$/)
809
+
810
+ if (part.type === "file" || part.type === "agent") {
811
+ const pill = createPill(part)
812
+ const gap = document.createTextNode(" ")
813
+ const range = selection.getRangeAt(0)
814
+
815
+ if (atMatch) {
816
+ const start = atMatch.index ?? cursorPosition - atMatch[0].length
817
+ setRangeEdge(range, "start", start)
818
+ setRangeEdge(range, "end", cursorPosition)
819
+ }
820
+
821
+ range.deleteContents()
822
+ range.insertNode(gap)
823
+ range.insertNode(pill)
824
+ range.setStartAfter(gap)
825
+ range.collapse(true)
826
+ selection.removeAllRanges()
827
+ selection.addRange(range)
828
+ } else if (part.type === "text") {
829
+ const range = selection.getRangeAt(0)
830
+ const fragment = createTextFragment(part.content)
831
+ const last = fragment.lastChild
832
+ range.deleteContents()
833
+ range.insertNode(fragment)
834
+ if (last) {
835
+ if (last.nodeType === Node.TEXT_NODE) {
836
+ const text = last.textContent ?? ""
837
+ if (text === "\u200B") {
838
+ range.setStart(last, 0)
839
+ }
840
+ if (text !== "\u200B") {
841
+ range.setStart(last, text.length)
842
+ }
843
+ }
844
+ if (last.nodeType !== Node.TEXT_NODE) {
845
+ range.setStartAfter(last)
846
+ }
847
+ }
848
+ range.collapse(true)
849
+ selection.removeAllRanges()
850
+ selection.addRange(range)
851
+ }
852
+
853
+ handleInput()
854
+ setStore("popover", null)
855
+ }
856
+
857
+ const abort = async () => {
858
+ const sessionID = params.id
859
+ if (!sessionID) return Promise.resolve()
860
+ const queued = pending.get(sessionID)
861
+ if (queued) {
862
+ queued.abort.abort()
863
+ queued.cleanup()
864
+ pending.delete(sessionID)
865
+ return Promise.resolve()
866
+ }
867
+ return sdk.client.session
868
+ .abort({
869
+ sessionID,
870
+ })
871
+ .catch(() => {})
872
+ }
873
+
874
+ const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
875
+ const text = prompt
876
+ .map((p) => ("content" in p ? p.content : ""))
877
+ .join("")
878
+ .trim()
879
+ const hasImages = prompt.some((part) => part.type === "image")
880
+ if (!text && !hasImages) return
881
+
882
+ const entry = clonePromptParts(prompt)
883
+ const currentHistory = mode === "shell" ? shellHistory : history
884
+ const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
885
+ const lastEntry = currentHistory.entries[0]
886
+ if (lastEntry && isPromptEqual(lastEntry, entry)) return
887
+
888
+ setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
889
+ }
890
+
891
+ const navigateHistory = (direction: "up" | "down") => {
892
+ const entries = store.mode === "shell" ? shellHistory.entries : history.entries
893
+ const current = store.historyIndex
894
+
895
+ if (direction === "up") {
896
+ if (entries.length === 0) return false
897
+ if (current === -1) {
898
+ setStore("savedPrompt", clonePromptParts(prompt.current()))
899
+ setStore("historyIndex", 0)
900
+ applyHistoryPrompt(entries[0], "start")
901
+ return true
902
+ }
903
+ if (current < entries.length - 1) {
904
+ const next = current + 1
905
+ setStore("historyIndex", next)
906
+ applyHistoryPrompt(entries[next], "start")
907
+ return true
908
+ }
909
+ return false
910
+ }
911
+
912
+ if (current > 0) {
913
+ const next = current - 1
914
+ setStore("historyIndex", next)
915
+ applyHistoryPrompt(entries[next], "end")
916
+ return true
917
+ }
918
+ if (current === 0) {
919
+ setStore("historyIndex", -1)
920
+ const saved = store.savedPrompt
921
+ if (saved) {
922
+ applyHistoryPrompt(saved, "end")
923
+ setStore("savedPrompt", null)
924
+ return true
925
+ }
926
+ applyHistoryPrompt(DEFAULT_PROMPT, "end")
927
+ return true
928
+ }
929
+
930
+ return false
931
+ }
932
+
933
+ const handleKeyDown = (event: KeyboardEvent) => {
934
+ if (event.key === "Backspace") {
935
+ const selection = window.getSelection()
936
+ if (selection && selection.isCollapsed) {
937
+ const node = selection.anchorNode
938
+ const offset = selection.anchorOffset
939
+ if (node && node.nodeType === Node.TEXT_NODE) {
940
+ const text = node.textContent ?? ""
941
+ if (/^\u200B+$/.test(text) && offset > 0) {
942
+ const range = document.createRange()
943
+ range.setStart(node, 0)
944
+ range.collapse(true)
945
+ selection.removeAllRanges()
946
+ selection.addRange(range)
947
+ }
948
+ }
949
+ }
950
+ }
951
+
952
+ if (event.key === "!" && store.mode === "normal") {
953
+ const cursorPosition = getCursorPosition(editorRef)
954
+ if (cursorPosition === 0) {
955
+ setStore("mode", "shell")
956
+ setStore("popover", null)
957
+ event.preventDefault()
958
+ return
959
+ }
960
+ }
961
+ if (store.mode === "shell") {
962
+ const { collapsed, cursorPosition, textLength } = getCaretState()
963
+ if (event.key === "Escape") {
964
+ setStore("mode", "normal")
965
+ event.preventDefault()
966
+ return
967
+ }
968
+ if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
969
+ setStore("mode", "normal")
970
+ event.preventDefault()
971
+ return
972
+ }
973
+ }
974
+
975
+ // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input
976
+ // and should always insert a newline regardless of composition state
977
+ if (event.key === "Enter" && event.shiftKey) {
978
+ addPart({ type: "text", content: "\n", start: 0, end: 0 })
979
+ event.preventDefault()
980
+ return
981
+ }
982
+
983
+ if (event.key === "Enter" && isImeComposing(event)) {
984
+ return
985
+ }
986
+
987
+ if (store.popover) {
988
+ if (event.key === "Tab") {
989
+ selectPopoverActive()
990
+ event.preventDefault()
991
+ return
992
+ }
993
+ if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter") {
994
+ if (store.popover === "at") {
995
+ atOnKeyDown(event)
996
+ event.preventDefault()
997
+ return
998
+ }
999
+ if (store.popover === "slash") {
1000
+ slashOnKeyDown(event)
1001
+ }
1002
+ event.preventDefault()
1003
+ return
1004
+ }
1005
+ }
1006
+
1007
+ const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
1008
+
1009
+ if (ctrl && event.code === "KeyG") {
1010
+ if (store.popover) {
1011
+ setStore("popover", null)
1012
+ event.preventDefault()
1013
+ return
1014
+ }
1015
+ if (working()) {
1016
+ abort()
1017
+ event.preventDefault()
1018
+ }
1019
+ return
1020
+ }
1021
+
1022
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
1023
+ if (event.altKey || event.ctrlKey || event.metaKey) return
1024
+ const { collapsed } = getCaretState()
1025
+ if (!collapsed) return
1026
+
1027
+ const cursorPosition = getCursorPosition(editorRef)
1028
+ const textLength = promptLength(prompt.current())
1029
+ const textContent = prompt
1030
+ .current()
1031
+ .map((part) => ("content" in part ? part.content : ""))
1032
+ .join("")
1033
+ const isEmpty = textContent.trim() === "" || textLength <= 1
1034
+ const hasNewlines = textContent.includes("\n")
1035
+ const inHistory = store.historyIndex >= 0
1036
+ const atStart = cursorPosition <= (isEmpty ? 1 : 0)
1037
+ const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
1038
+ const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
1039
+ const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
1040
+
1041
+ if (event.key === "ArrowUp") {
1042
+ if (!allowUp) return
1043
+ if (navigateHistory("up")) {
1044
+ event.preventDefault()
1045
+ }
1046
+ return
1047
+ }
1048
+
1049
+ if (!allowDown) return
1050
+ if (navigateHistory("down")) {
1051
+ event.preventDefault()
1052
+ }
1053
+ return
1054
+ }
1055
+
1056
+ // Note: Shift+Enter is handled earlier, before IME check
1057
+ if (event.key === "Enter" && !event.shiftKey) {
1058
+ handleSubmit(event)
1059
+ }
1060
+ if (event.key === "Escape") {
1061
+ if (store.popover) {
1062
+ setStore("popover", null)
1063
+ } else if (working()) {
1064
+ abort()
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ const handleSubmit = async (event: Event) => {
1070
+ event.preventDefault()
1071
+
1072
+ const currentPrompt = prompt.current()
1073
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
1074
+ const images = imageAttachments().slice()
1075
+ const mode = store.mode
1076
+
1077
+ if (text.trim().length === 0 && images.length === 0) {
1078
+ if (working()) abort()
1079
+ return
1080
+ }
1081
+
1082
+ const currentModel = local.model.current()
1083
+ const currentAgent = local.agent.current()
1084
+ if (!currentModel || !currentAgent) {
1085
+ showToast({
1086
+ title: language.t("prompt.toast.modelAgentRequired.title"),
1087
+ description: language.t("prompt.toast.modelAgentRequired.description"),
1088
+ })
1089
+ return
1090
+ }
1091
+
1092
+ const errorMessage = (err: unknown) => {
1093
+ if (err && typeof err === "object" && "data" in err) {
1094
+ const data = (err as { data?: { message?: string } }).data
1095
+ if (data?.message) return data.message
1096
+ }
1097
+ if (err instanceof Error) return err.message
1098
+ return language.t("common.requestFailed")
1099
+ }
1100
+
1101
+ addToHistory(currentPrompt, mode)
1102
+ setStore("historyIndex", -1)
1103
+ setStore("savedPrompt", null)
1104
+
1105
+ const projectDirectory = sdk.directory
1106
+ const isNewSession = !params.id
1107
+ const worktreeSelection = props.newSessionWorktree ?? "main"
1108
+
1109
+ let sessionDirectory = projectDirectory
1110
+ let client = sdk.client
1111
+
1112
+ if (isNewSession) {
1113
+ if (worktreeSelection === "create") {
1114
+ const createdWorktree = await client.worktree
1115
+ .create({ directory: projectDirectory })
1116
+ .then((x) => x.data)
1117
+ .catch((err) => {
1118
+ showToast({
1119
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
1120
+ description: errorMessage(err),
1121
+ })
1122
+ return undefined
1123
+ })
1124
+
1125
+ if (!createdWorktree?.directory) {
1126
+ showToast({
1127
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
1128
+ description: language.t("common.requestFailed"),
1129
+ })
1130
+ return
1131
+ }
1132
+ WorktreeState.pending(createdWorktree.directory)
1133
+ sessionDirectory = createdWorktree.directory
1134
+ }
1135
+
1136
+ if (worktreeSelection !== "main" && worktreeSelection !== "create") {
1137
+ sessionDirectory = worktreeSelection
1138
+ }
1139
+
1140
+ if (sessionDirectory !== projectDirectory) {
1141
+ client = createOpencodeClient({
1142
+ baseUrl: sdk.url,
1143
+ fetch: platform.fetch,
1144
+ directory: sessionDirectory,
1145
+ throwOnError: true,
1146
+ })
1147
+ globalSync.child(sessionDirectory)
1148
+ }
1149
+
1150
+ props.onNewSessionWorktreeReset?.()
1151
+ }
1152
+
1153
+ let session = info()
1154
+ if (!session && isNewSession) {
1155
+ session = await client.session
1156
+ .create()
1157
+ .then((x) => x.data ?? undefined)
1158
+ .catch((err) => {
1159
+ showToast({
1160
+ title: language.t("prompt.toast.sessionCreateFailed.title"),
1161
+ description: errorMessage(err),
1162
+ })
1163
+ return undefined
1164
+ })
1165
+ if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
1166
+ }
1167
+ if (!session) return
1168
+
1169
+ props.onSubmit?.()
1170
+
1171
+ const model = {
1172
+ modelID: currentModel.id,
1173
+ providerID: currentModel.provider.id,
1174
+ }
1175
+ const agent = currentAgent.name
1176
+ const variant = local.model.variant.current()
1177
+
1178
+ const clearInput = () => {
1179
+ prompt.reset()
1180
+ setStore("mode", "normal")
1181
+ setStore("popover", null)
1182
+ }
1183
+
1184
+ const restoreInput = () => {
1185
+ prompt.set(currentPrompt, promptLength(currentPrompt))
1186
+ setStore("mode", mode)
1187
+ setStore("popover", null)
1188
+ requestAnimationFrame(() => {
1189
+ editorRef.focus()
1190
+ setCursorPosition(editorRef, promptLength(currentPrompt))
1191
+ queueScroll()
1192
+ })
1193
+ }
1194
+
1195
+ if (mode === "shell") {
1196
+ clearInput()
1197
+ client.session
1198
+ .shell({
1199
+ sessionID: session.id,
1200
+ agent,
1201
+ model,
1202
+ command: text,
1203
+ })
1204
+ .catch((err) => {
1205
+ showToast({
1206
+ title: language.t("prompt.toast.shellSendFailed.title"),
1207
+ description: errorMessage(err),
1208
+ })
1209
+ restoreInput()
1210
+ })
1211
+ return
1212
+ }
1213
+
1214
+ if (text.startsWith("/")) {
1215
+ const [cmdName, ...args] = text.split(" ")
1216
+ const commandName = cmdName.slice(1)
1217
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
1218
+ if (customCommand) {
1219
+ clearInput()
1220
+ client.session
1221
+ .command({
1222
+ sessionID: session.id,
1223
+ command: commandName,
1224
+ arguments: args.join(" "),
1225
+ agent,
1226
+ model: `${model.providerID}/${model.modelID}`,
1227
+ variant,
1228
+ parts: images.map((attachment) => ({
1229
+ id: Identifier.ascending("part"),
1230
+ type: "file" as const,
1231
+ mime: attachment.mime,
1232
+ url: attachment.dataUrl,
1233
+ filename: attachment.filename,
1234
+ })),
1235
+ })
1236
+ .catch((err) => {
1237
+ showToast({
1238
+ title: language.t("prompt.toast.commandSendFailed.title"),
1239
+ description: errorMessage(err),
1240
+ })
1241
+ restoreInput()
1242
+ })
1243
+ return
1244
+ }
1245
+ }
1246
+
1247
+ const toAbsolutePath = (path: string) =>
1248
+ path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
1249
+
1250
+ const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
1251
+ const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
1252
+
1253
+ const fileAttachmentParts = fileAttachments.map((attachment) => {
1254
+ const absolute = toAbsolutePath(attachment.path)
1255
+ const query = attachment.selection
1256
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
1257
+ : ""
1258
+ return {
1259
+ id: Identifier.ascending("part"),
1260
+ type: "file" as const,
1261
+ mime: "text/plain",
1262
+ url: `file://${absolute}${query}`,
1263
+ filename: getFilename(attachment.path),
1264
+ source: {
1265
+ type: "file" as const,
1266
+ text: {
1267
+ value: attachment.content,
1268
+ start: attachment.start,
1269
+ end: attachment.end,
1270
+ },
1271
+ path: absolute,
1272
+ },
1273
+ }
1274
+ })
1275
+
1276
+ const agentAttachmentParts = agentAttachments.map((attachment) => ({
1277
+ id: Identifier.ascending("part"),
1278
+ type: "agent" as const,
1279
+ name: attachment.name,
1280
+ source: {
1281
+ value: attachment.content,
1282
+ start: attachment.start,
1283
+ end: attachment.end,
1284
+ },
1285
+ }))
1286
+
1287
+ const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
1288
+
1289
+ const context = prompt.context.items().slice()
1290
+
1291
+ const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
1292
+
1293
+ const contextParts: Array<
1294
+ | {
1295
+ id: string
1296
+ type: "text"
1297
+ text: string
1298
+ synthetic?: boolean
1299
+ }
1300
+ | {
1301
+ id: string
1302
+ type: "file"
1303
+ mime: string
1304
+ url: string
1305
+ filename?: string
1306
+ }
1307
+ > = []
1308
+
1309
+ const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
1310
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
1311
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
1312
+ const range =
1313
+ start === undefined || end === undefined
1314
+ ? "this file"
1315
+ : start === end
1316
+ ? `line ${start}`
1317
+ : `lines ${start} through ${end}`
1318
+
1319
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
1320
+ }
1321
+
1322
+ const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
1323
+ const absolute = toAbsolutePath(input.path)
1324
+ const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
1325
+ const url = `file://${absolute}${query}`
1326
+
1327
+ const comment = input.comment?.trim()
1328
+ if (!comment && usedUrls.has(url)) return
1329
+ usedUrls.add(url)
1330
+
1331
+ if (comment) {
1332
+ contextParts.push({
1333
+ id: Identifier.ascending("part"),
1334
+ type: "text",
1335
+ text: commentNote(input.path, input.selection, comment),
1336
+ synthetic: true,
1337
+ })
1338
+ }
1339
+
1340
+ contextParts.push({
1341
+ id: Identifier.ascending("part"),
1342
+ type: "file",
1343
+ mime: "text/plain",
1344
+ url,
1345
+ filename: getFilename(input.path),
1346
+ })
1347
+ }
1348
+
1349
+ for (const item of context) {
1350
+ if (item.type !== "file") continue
1351
+ addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
1352
+ }
1353
+
1354
+ const imageAttachmentParts = images.map((attachment) => ({
1355
+ id: Identifier.ascending("part"),
1356
+ type: "file" as const,
1357
+ mime: attachment.mime,
1358
+ url: attachment.dataUrl,
1359
+ filename: attachment.filename,
1360
+ }))
1361
+
1362
+ const messageID = Identifier.ascending("message")
1363
+ const textPart = {
1364
+ id: Identifier.ascending("part"),
1365
+ type: "text" as const,
1366
+ text,
1367
+ }
1368
+ const requestParts = [
1369
+ textPart,
1370
+ ...fileAttachmentParts,
1371
+ ...contextParts,
1372
+ ...agentAttachmentParts,
1373
+ ...imageAttachmentParts,
1374
+ ]
1375
+
1376
+ const optimisticParts = requestParts.map((part) => ({
1377
+ ...part,
1378
+ sessionID: session.id,
1379
+ messageID,
1380
+ })) as unknown as Part[]
1381
+
1382
+ const optimisticMessage: Message = {
1383
+ id: messageID,
1384
+ sessionID: session.id,
1385
+ role: "user",
1386
+ time: { created: Date.now() },
1387
+ agent,
1388
+ model,
1389
+ }
1390
+
1391
+ const addOptimisticMessage = () => {
1392
+ if (sessionDirectory === projectDirectory) {
1393
+ sync.set(
1394
+ produce((draft) => {
1395
+ const messages = draft.message[session.id]
1396
+ if (!messages) {
1397
+ draft.message[session.id] = [optimisticMessage]
1398
+ } else {
1399
+ const result = Binary.search(messages, messageID, (m) => m.id)
1400
+ messages.splice(result.index, 0, optimisticMessage)
1401
+ }
1402
+ draft.part[messageID] = optimisticParts
1403
+ .filter((p) => !!p?.id)
1404
+ .slice()
1405
+ .sort((a, b) => a.id.localeCompare(b.id))
1406
+ }),
1407
+ )
1408
+ return
1409
+ }
1410
+
1411
+ globalSync.child(sessionDirectory)[1](
1412
+ produce((draft) => {
1413
+ const messages = draft.message[session.id]
1414
+ if (!messages) {
1415
+ draft.message[session.id] = [optimisticMessage]
1416
+ } else {
1417
+ const result = Binary.search(messages, messageID, (m) => m.id)
1418
+ messages.splice(result.index, 0, optimisticMessage)
1419
+ }
1420
+ draft.part[messageID] = optimisticParts
1421
+ .filter((p) => !!p?.id)
1422
+ .slice()
1423
+ .sort((a, b) => a.id.localeCompare(b.id))
1424
+ }),
1425
+ )
1426
+ }
1427
+
1428
+ const removeOptimisticMessage = () => {
1429
+ if (sessionDirectory === projectDirectory) {
1430
+ sync.set(
1431
+ produce((draft) => {
1432
+ const messages = draft.message[session.id]
1433
+ if (messages) {
1434
+ const result = Binary.search(messages, messageID, (m) => m.id)
1435
+ if (result.found) messages.splice(result.index, 1)
1436
+ }
1437
+ delete draft.part[messageID]
1438
+ }),
1439
+ )
1440
+ return
1441
+ }
1442
+
1443
+ globalSync.child(sessionDirectory)[1](
1444
+ produce((draft) => {
1445
+ const messages = draft.message[session.id]
1446
+ if (messages) {
1447
+ const result = Binary.search(messages, messageID, (m) => m.id)
1448
+ if (result.found) messages.splice(result.index, 1)
1449
+ }
1450
+ delete draft.part[messageID]
1451
+ }),
1452
+ )
1453
+ }
1454
+
1455
+ for (const item of commentItems) {
1456
+ prompt.context.remove(item.key)
1457
+ }
1458
+
1459
+ clearInput()
1460
+ addOptimisticMessage()
1461
+
1462
+ const waitForWorktree = async () => {
1463
+ const worktree = WorktreeState.get(sessionDirectory)
1464
+ if (!worktree || worktree.status !== "pending") return true
1465
+
1466
+ if (sessionDirectory === projectDirectory) {
1467
+ sync.set("session_status", session.id, { type: "busy" })
1468
+ }
1469
+
1470
+ const controller = new AbortController()
1471
+
1472
+ const cleanup = () => {
1473
+ if (sessionDirectory === projectDirectory) {
1474
+ sync.set("session_status", session.id, { type: "idle" })
1475
+ }
1476
+ removeOptimisticMessage()
1477
+ for (const item of commentItems) {
1478
+ prompt.context.add({
1479
+ type: "file",
1480
+ path: item.path,
1481
+ selection: item.selection,
1482
+ comment: item.comment,
1483
+ commentID: item.commentID,
1484
+ preview: item.preview,
1485
+ })
1486
+ }
1487
+ restoreInput()
1488
+ }
1489
+
1490
+ pending.set(session.id, { abort: controller, cleanup })
1491
+
1492
+ const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
1493
+ if (controller.signal.aborted) {
1494
+ resolve({ status: "failed", message: "aborted" })
1495
+ return
1496
+ }
1497
+ controller.signal.addEventListener(
1498
+ "abort",
1499
+ () => {
1500
+ resolve({ status: "failed", message: "aborted" })
1501
+ },
1502
+ { once: true },
1503
+ )
1504
+ })
1505
+
1506
+ const timeoutMs = 5 * 60 * 1000
1507
+ const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
1508
+ setTimeout(() => {
1509
+ resolve({ status: "failed", message: "Workspace is still preparing" })
1510
+ }, timeoutMs)
1511
+ })
1512
+
1513
+ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout])
1514
+ pending.delete(session.id)
1515
+ if (controller.signal.aborted) return false
1516
+ if (result.status === "failed") throw new Error(result.message)
1517
+ return true
1518
+ }
1519
+
1520
+ const send = async () => {
1521
+ const ok = await waitForWorktree()
1522
+ if (!ok) return
1523
+ await client.session.prompt({
1524
+ sessionID: session.id,
1525
+ agent,
1526
+ model,
1527
+ messageID,
1528
+ parts: requestParts,
1529
+ variant,
1530
+ })
1531
+ }
1532
+
1533
+ void send().catch((err) => {
1534
+ pending.delete(session.id)
1535
+ if (sessionDirectory === projectDirectory) {
1536
+ sync.set("session_status", session.id, { type: "idle" })
1537
+ }
1538
+ showToast({
1539
+ title: language.t("prompt.toast.promptSendFailed.title"),
1540
+ description: errorMessage(err),
1541
+ })
1542
+ removeOptimisticMessage()
1543
+ for (const item of commentItems) {
1544
+ prompt.context.add({
1545
+ type: "file",
1546
+ path: item.path,
1547
+ selection: item.selection,
1548
+ comment: item.comment,
1549
+ commentID: item.commentID,
1550
+ preview: item.preview,
1551
+ })
1552
+ }
1553
+ restoreInput()
1554
+ })
1555
+ }
1556
+
1557
+ return (
1558
+ <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
1559
+ <Show when={store.popover}>
1560
+ <div
1561
+ ref={(el) => {
1562
+ if (store.popover === "slash") slashPopoverRef = el
1563
+ }}
1564
+ class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
1565
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-md
1566
+ border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
1567
+ onMouseDown={(e) => e.preventDefault()}
1568
+ >
1569
+ <Switch>
1570
+ <Match when={store.popover === "at"}>
1571
+ <Show
1572
+ when={atFlat().length > 0}
1573
+ fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyResults")}</div>}
1574
+ >
1575
+ <For each={atFlat().slice(0, 10)}>
1576
+ {(item) => (
1577
+ <button
1578
+ classList={{
1579
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
1580
+ "bg-surface-raised-base-hover": atActive() === atKey(item),
1581
+ }}
1582
+ onClick={() => handleAtSelect(item)}
1583
+ onMouseEnter={() => setAtActive(atKey(item))}
1584
+ >
1585
+ <Show
1586
+ when={item.type === "agent"}
1587
+ fallback={
1588
+ <>
1589
+ <FileIcon
1590
+ node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
1591
+ class="shrink-0 size-4"
1592
+ />
1593
+ <div class="flex items-center text-14-regular min-w-0">
1594
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">
1595
+ {(() => {
1596
+ const path = (item as { type: "file"; path: string }).path
1597
+ return path.endsWith("/") ? path : getDirectory(path)
1598
+ })()}
1599
+ </span>
1600
+ <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
1601
+ <span class="text-text-strong whitespace-nowrap">
1602
+ {getFilename((item as { type: "file"; path: string }).path)}
1603
+ </span>
1604
+ </Show>
1605
+ </div>
1606
+ </>
1607
+ }
1608
+ >
1609
+ <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
1610
+ <span class="text-14-regular text-text-strong whitespace-nowrap">
1611
+ @{(item as { type: "agent"; name: string }).name}
1612
+ </span>
1613
+ </Show>
1614
+ </button>
1615
+ )}
1616
+ </For>
1617
+ </Show>
1618
+ </Match>
1619
+ <Match when={store.popover === "slash"}>
1620
+ <Show
1621
+ when={slashFlat().length > 0}
1622
+ fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyCommands")}</div>}
1623
+ >
1624
+ <For each={slashFlat()}>
1625
+ {(cmd) => (
1626
+ <button
1627
+ data-slash-id={cmd.id}
1628
+ classList={{
1629
+ "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
1630
+ "bg-surface-raised-base-hover": slashActive() === cmd.id,
1631
+ }}
1632
+ onClick={() => handleSlashSelect(cmd)}
1633
+ onMouseEnter={() => setSlashActive(cmd.id)}
1634
+ >
1635
+ <div class="flex items-center gap-2 min-w-0">
1636
+ <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
1637
+ <Show when={cmd.description}>
1638
+ <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
1639
+ </Show>
1640
+ </div>
1641
+ <div class="flex items-center gap-2 shrink-0">
1642
+ <Show when={cmd.type === "custom"}>
1643
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
1644
+ {language.t("prompt.slash.badge.custom")}
1645
+ </span>
1646
+ </Show>
1647
+ <Show when={command.keybind(cmd.id)}>
1648
+ <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
1649
+ </Show>
1650
+ </div>
1651
+ </button>
1652
+ )}
1653
+ </For>
1654
+ </Show>
1655
+ </Match>
1656
+ </Switch>
1657
+ </div>
1658
+ </Show>
1659
+ <form
1660
+ onSubmit={handleSubmit}
1661
+ classList={{
1662
+ "group/prompt-input": true,
1663
+ "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
1664
+ "rounded-md overflow-clip focus-within:shadow-xs-border": true,
1665
+ "border-icon-info-active border-dashed": store.dragging,
1666
+ [props.class ?? ""]: !!props.class,
1667
+ }}
1668
+ >
1669
+ <Show when={store.dragging}>
1670
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
1671
+ <div class="flex flex-col items-center gap-2 text-text-weak">
1672
+ <Icon name="photo" class="size-8" />
1673
+ <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
1674
+ </div>
1675
+ </div>
1676
+ </Show>
1677
+ <Show when={prompt.context.items().length > 0}>
1678
+ <div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
1679
+ <For each={prompt.context.items()}>
1680
+ {(item) => {
1681
+ return (
1682
+ <div
1683
+ classList={{
1684
+ "shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
1685
+ "cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
1686
+ }}
1687
+ onClick={() => {
1688
+ if (!item.commentID) return
1689
+ comments.setFocus({ file: item.path, id: item.commentID })
1690
+ view().reviewPanel.open()
1691
+ tabs().open("review")
1692
+ }}
1693
+ >
1694
+ <div class="flex items-center gap-1.5">
1695
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
1696
+ <div class="flex items-center text-11-regular min-w-0">
1697
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
1698
+ <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
1699
+ <Show when={item.selection}>
1700
+ {(sel) => (
1701
+ <span class="text-text-weak whitespace-nowrap ml-1">
1702
+ {sel().startLine === sel().endLine
1703
+ ? `:${sel().startLine}`
1704
+ : `:${sel().startLine}-${sel().endLine}`}
1705
+ </span>
1706
+ )}
1707
+ </Show>
1708
+ </div>
1709
+ <IconButton
1710
+ type="button"
1711
+ icon="close"
1712
+ variant="ghost"
1713
+ class="h-5 w-5"
1714
+ onClick={(e) => {
1715
+ e.stopPropagation()
1716
+ if (item.commentID) comments.remove(item.path, item.commentID)
1717
+ prompt.context.remove(item.key)
1718
+ }}
1719
+ aria-label={language.t("prompt.context.removeFile")}
1720
+ />
1721
+ </div>
1722
+ <Show when={item.comment}>
1723
+ {(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
1724
+ </Show>
1725
+ </div>
1726
+ )
1727
+ }}
1728
+ </For>
1729
+ </div>
1730
+ </Show>
1731
+ <Show when={imageAttachments().length > 0}>
1732
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
1733
+ <For each={imageAttachments()}>
1734
+ {(attachment) => (
1735
+ <div class="relative group">
1736
+ <Show
1737
+ when={attachment.mime.startsWith("image/")}
1738
+ fallback={
1739
+ <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
1740
+ <Icon name="folder" class="size-6 text-text-weak" />
1741
+ </div>
1742
+ }
1743
+ >
1744
+ <img
1745
+ src={attachment.dataUrl}
1746
+ alt={attachment.filename}
1747
+ class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
1748
+ onClick={() =>
1749
+ dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
1750
+ }
1751
+ />
1752
+ </Show>
1753
+ <button
1754
+ type="button"
1755
+ onClick={() => removeImageAttachment(attachment.id)}
1756
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
1757
+ aria-label={language.t("prompt.attachment.remove")}
1758
+ >
1759
+ <Icon name="close" class="size-3 text-text-weak" />
1760
+ </button>
1761
+ <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
1762
+ <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
1763
+ </div>
1764
+ </div>
1765
+ )}
1766
+ </For>
1767
+ </div>
1768
+ </Show>
1769
+ <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
1770
+ <div
1771
+ data-component="prompt-input"
1772
+ ref={(el) => {
1773
+ editorRef = el
1774
+ props.ref?.(el)
1775
+ }}
1776
+ role="textbox"
1777
+ aria-multiline="true"
1778
+ aria-label={
1779
+ store.mode === "shell"
1780
+ ? language.t("prompt.placeholder.shell")
1781
+ : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
1782
+ }
1783
+ contenteditable="true"
1784
+ onInput={handleInput}
1785
+ onPaste={handlePaste}
1786
+ onCompositionStart={() => setComposing(true)}
1787
+ onCompositionEnd={() => setComposing(false)}
1788
+ onKeyDown={handleKeyDown}
1789
+ classList={{
1790
+ "select-text": true,
1791
+ "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
1792
+ "[&_[data-type=file]]:text-syntax-property": true,
1793
+ "[&_[data-type=agent]]:text-syntax-type": true,
1794
+ "font-mono!": store.mode === "shell",
1795
+ }}
1796
+ />
1797
+ <Show when={!prompt.dirty()}>
1798
+ <div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
1799
+ {store.mode === "shell"
1800
+ ? language.t("prompt.placeholder.shell")
1801
+ : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
1802
+ </div>
1803
+ </Show>
1804
+ </div>
1805
+ <div class="relative p-3 flex items-center justify-between">
1806
+ <div class="flex items-center justify-start gap-0.5">
1807
+ <Switch>
1808
+ <Match when={store.mode === "shell"}>
1809
+ <div class="flex items-center gap-2 px-2 h-6">
1810
+ <Icon name="console" size="small" class="text-icon-primary" />
1811
+ <span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
1812
+ <span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
1813
+ </div>
1814
+ </Match>
1815
+ <Match when={store.mode === "normal"}>
1816
+ <TooltipKeybind
1817
+ placement="top"
1818
+ title={language.t("command.agent.cycle")}
1819
+ keybind={command.keybind("agent.cycle")}
1820
+ >
1821
+ <Select
1822
+ options={local.agent.list().map((agent) => agent.name)}
1823
+ current={local.agent.current()?.name ?? ""}
1824
+ onSelect={local.agent.set}
1825
+ class="capitalize"
1826
+ variant="ghost"
1827
+ />
1828
+ </TooltipKeybind>
1829
+ <Show
1830
+ when={providers.paid().length > 0}
1831
+ fallback={
1832
+ <TooltipKeybind
1833
+ placement="top"
1834
+ title={language.t("command.model.choose")}
1835
+ keybind={command.keybind("model.choose")}
1836
+ >
1837
+ <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
1838
+ <Show when={local.model.current()?.provider?.id}>
1839
+ <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
1840
+ </Show>
1841
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
1842
+ <Icon name="chevron-down" size="small" />
1843
+ </Button>
1844
+ </TooltipKeybind>
1845
+ }
1846
+ >
1847
+ <TooltipKeybind
1848
+ placement="top"
1849
+ title={language.t("command.model.choose")}
1850
+ keybind={command.keybind("model.choose")}
1851
+ >
1852
+ <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
1853
+ <Show when={local.model.current()?.provider?.id}>
1854
+ <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
1855
+ </Show>
1856
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
1857
+ <Icon name="chevron-down" size="small" />
1858
+ </ModelSelectorPopover>
1859
+ </TooltipKeybind>
1860
+ </Show>
1861
+ <Show when={local.model.variant.list().length > 0}>
1862
+ <TooltipKeybind
1863
+ placement="top"
1864
+ title={language.t("command.model.variant.cycle")}
1865
+ keybind={command.keybind("model.variant.cycle")}
1866
+ >
1867
+ <Button
1868
+ variant="ghost"
1869
+ class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
1870
+ onClick={() => local.model.variant.cycle()}
1871
+ >
1872
+ {local.model.variant.current() ?? language.t("common.default")}
1873
+ </Button>
1874
+ </TooltipKeybind>
1875
+ </Show>
1876
+ <Show when={permission.permissionsEnabled() && params.id}>
1877
+ <TooltipKeybind
1878
+ placement="top"
1879
+ title={language.t("command.permissions.autoaccept.enable")}
1880
+ keybind={command.keybind("permissions.autoaccept")}
1881
+ >
1882
+ <Button
1883
+ variant="ghost"
1884
+ onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
1885
+ classList={{
1886
+ "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
1887
+ "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
1888
+ "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
1889
+ }}
1890
+ aria-label={
1891
+ permission.isAutoAccepting(params.id!, sdk.directory)
1892
+ ? language.t("command.permissions.autoaccept.disable")
1893
+ : language.t("command.permissions.autoaccept.enable")
1894
+ }
1895
+ aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
1896
+ >
1897
+ <Icon
1898
+ name="chevron-double-right"
1899
+ size="small"
1900
+ classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
1901
+ />
1902
+ </Button>
1903
+ </TooltipKeybind>
1904
+ </Show>
1905
+ </Match>
1906
+ </Switch>
1907
+ </div>
1908
+ <div class="flex items-center gap-3 absolute right-2 bottom-2">
1909
+ <input
1910
+ ref={fileInputRef}
1911
+ type="file"
1912
+ accept={ACCEPTED_FILE_TYPES.join(",")}
1913
+ class="hidden"
1914
+ onChange={(e) => {
1915
+ const file = e.currentTarget.files?.[0]
1916
+ if (file) addImageAttachment(file)
1917
+ e.currentTarget.value = ""
1918
+ }}
1919
+ />
1920
+ <div class="flex items-center gap-2">
1921
+ <SessionContextUsage />
1922
+ <Show when={store.mode === "normal"}>
1923
+ <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
1924
+ <Button
1925
+ type="button"
1926
+ variant="ghost"
1927
+ class="size-6"
1928
+ onClick={() => fileInputRef.click()}
1929
+ aria-label={language.t("prompt.action.attachFile")}
1930
+ >
1931
+ <Icon name="photo" class="size-4.5" />
1932
+ </Button>
1933
+ </Tooltip>
1934
+ </Show>
1935
+ </div>
1936
+ <Tooltip
1937
+ placement="top"
1938
+ inactive={!prompt.dirty() && !working()}
1939
+ value={
1940
+ <Switch>
1941
+ <Match when={working()}>
1942
+ <div class="flex items-center gap-2">
1943
+ <span>{language.t("prompt.action.stop")}</span>
1944
+ <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
1945
+ </div>
1946
+ </Match>
1947
+ <Match when={true}>
1948
+ <div class="flex items-center gap-2">
1949
+ <span>{language.t("prompt.action.send")}</span>
1950
+ <Icon name="enter" size="small" class="text-icon-base" />
1951
+ </div>
1952
+ </Match>
1953
+ </Switch>
1954
+ }
1955
+ >
1956
+ <IconButton
1957
+ type="submit"
1958
+ disabled={!prompt.dirty() && !working()}
1959
+ icon={working() ? "stop" : "arrow-up"}
1960
+ variant="primary"
1961
+ class="h-6 w-4.5"
1962
+ aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
1963
+ />
1964
+ </Tooltip>
1965
+ </div>
1966
+ </div>
1967
+ </form>
1968
+ </div>
1969
+ )
1970
+ }
1971
+
1972
+ function createTextFragment(content: string): DocumentFragment {
1973
+ const fragment = document.createDocumentFragment()
1974
+ const segments = content.split("\n")
1975
+ segments.forEach((segment, index) => {
1976
+ if (segment) {
1977
+ fragment.appendChild(document.createTextNode(segment))
1978
+ } else if (segments.length > 1) {
1979
+ fragment.appendChild(document.createTextNode("\u200B"))
1980
+ }
1981
+ if (index < segments.length - 1) {
1982
+ fragment.appendChild(document.createElement("br"))
1983
+ }
1984
+ })
1985
+ return fragment
1986
+ }
1987
+
1988
+ function getNodeLength(node: Node): number {
1989
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
1990
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
1991
+ }
1992
+
1993
+ function getTextLength(node: Node): number {
1994
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
1995
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
1996
+ let length = 0
1997
+ for (const child of Array.from(node.childNodes)) {
1998
+ length += getTextLength(child)
1999
+ }
2000
+ return length
2001
+ }
2002
+
2003
+ function getCursorPosition(parent: HTMLElement): number {
2004
+ const selection = window.getSelection()
2005
+ if (!selection || selection.rangeCount === 0) return 0
2006
+ const range = selection.getRangeAt(0)
2007
+ if (!parent.contains(range.startContainer)) return 0
2008
+ const preCaretRange = range.cloneRange()
2009
+ preCaretRange.selectNodeContents(parent)
2010
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
2011
+ return getTextLength(preCaretRange.cloneContents())
2012
+ }
2013
+
2014
+ function setCursorPosition(parent: HTMLElement, position: number) {
2015
+ let remaining = position
2016
+ let node = parent.firstChild
2017
+ while (node) {
2018
+ const length = getNodeLength(node)
2019
+ const isText = node.nodeType === Node.TEXT_NODE
2020
+ const isPill =
2021
+ node.nodeType === Node.ELEMENT_NODE &&
2022
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
2023
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
2024
+
2025
+ if (isText && remaining <= length) {
2026
+ const range = document.createRange()
2027
+ const selection = window.getSelection()
2028
+ range.setStart(node, remaining)
2029
+ range.collapse(true)
2030
+ selection?.removeAllRanges()
2031
+ selection?.addRange(range)
2032
+ return
2033
+ }
2034
+
2035
+ if ((isPill || isBreak) && remaining <= length) {
2036
+ const range = document.createRange()
2037
+ const selection = window.getSelection()
2038
+ if (remaining === 0) {
2039
+ range.setStartBefore(node)
2040
+ }
2041
+ if (remaining > 0 && isPill) {
2042
+ range.setStartAfter(node)
2043
+ }
2044
+ if (remaining > 0 && isBreak) {
2045
+ const next = node.nextSibling
2046
+ if (next && next.nodeType === Node.TEXT_NODE) {
2047
+ range.setStart(next, 0)
2048
+ }
2049
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
2050
+ range.setStartAfter(node)
2051
+ }
2052
+ }
2053
+ range.collapse(true)
2054
+ selection?.removeAllRanges()
2055
+ selection?.addRange(range)
2056
+ return
2057
+ }
2058
+
2059
+ remaining -= length
2060
+ node = node.nextSibling
2061
+ }
2062
+
2063
+ const fallbackRange = document.createRange()
2064
+ const fallbackSelection = window.getSelection()
2065
+ const last = parent.lastChild
2066
+ if (last && last.nodeType === Node.TEXT_NODE) {
2067
+ const len = last.textContent ? last.textContent.length : 0
2068
+ fallbackRange.setStart(last, len)
2069
+ }
2070
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
2071
+ fallbackRange.selectNodeContents(parent)
2072
+ }
2073
+ fallbackRange.collapse(false)
2074
+ fallbackSelection?.removeAllRanges()
2075
+ fallbackSelection?.addRange(fallbackRange)
2076
+ }