prompt-input.tsx 70 KB

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