@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.
- package/AGENTS.md +30 -0
- package/README.md +51 -0
- package/bunfig.toml +2 -0
- package/e2e/context.spec.ts +45 -0
- package/e2e/file-open.spec.ts +23 -0
- package/e2e/file-viewer.spec.ts +35 -0
- package/e2e/fixtures.ts +40 -0
- package/e2e/home.spec.ts +21 -0
- package/e2e/model-picker.spec.ts +43 -0
- package/e2e/navigation.spec.ts +9 -0
- package/e2e/palette.spec.ts +15 -0
- package/e2e/prompt-mention.spec.ts +26 -0
- package/e2e/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt.spec.ts +62 -0
- package/e2e/session.spec.ts +21 -0
- package/e2e/settings.spec.ts +44 -0
- package/e2e/sidebar.spec.ts +21 -0
- package/e2e/terminal-init.spec.ts +25 -0
- package/e2e/terminal.spec.ts +16 -0
- package/e2e/tsconfig.json +8 -0
- package/e2e/utils.ts +38 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +72 -0
- package/playwright.config.ts +43 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +28 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/e2e-local.ts +143 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +591 -0
- package/src/app.tsx +150 -0
- package/src/components/dialog-connect-provider.tsx +428 -0
- package/src/components/dialog-edit-project.tsx +259 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +59 -0
- package/src/components/dialog-select-directory.tsx +208 -0
- package/src/components/dialog-select-file.tsx +196 -0
- package/src/components/dialog-select-mcp.tsx +96 -0
- package/src/components/dialog-select-model-unpaid.tsx +130 -0
- package/src/components/dialog-select-model.tsx +162 -0
- package/src/components/dialog-select-provider.tsx +70 -0
- package/src/components/dialog-select-server.tsx +249 -0
- package/src/components/dialog-settings.tsx +112 -0
- package/src/components/file-tree.tsx +112 -0
- package/src/components/link.tsx +17 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input.tsx +2076 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-tab.tsx +428 -0
- package/src/components/session/session-header.tsx +343 -0
- package/src/components/session/session-new-view.tsx +93 -0
- package/src/components/session/session-sortable-tab.tsx +56 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
- package/src/components/session-context-usage.tsx +113 -0
- package/src/components/session-lsp-indicator.tsx +42 -0
- package/src/components/session-mcp-indicator.tsx +34 -0
- package/src/components/settings-agents.tsx +15 -0
- package/src/components/settings-commands.tsx +15 -0
- package/src/components/settings-general.tsx +306 -0
- package/src/components/settings-keybinds.tsx +437 -0
- package/src/components/settings-mcp.tsx +15 -0
- package/src/components/settings-models.tsx +15 -0
- package/src/components/settings-permissions.tsx +234 -0
- package/src/components/settings-providers.tsx +15 -0
- package/src/components/terminal.tsx +315 -0
- package/src/components/titlebar.tsx +156 -0
- package/src/context/command.tsx +308 -0
- package/src/context/comments.tsx +140 -0
- package/src/context/file.tsx +409 -0
- package/src/context/global-sdk.tsx +106 -0
- package/src/context/global-sync.tsx +898 -0
- package/src/context/language.tsx +161 -0
- package/src/context/layout-scroll.test.ts +73 -0
- package/src/context/layout-scroll.ts +118 -0
- package/src/context/layout.tsx +648 -0
- package/src/context/local.tsx +578 -0
- package/src/context/notification.tsx +173 -0
- package/src/context/permission.tsx +167 -0
- package/src/context/platform.tsx +59 -0
- package/src/context/prompt.tsx +245 -0
- package/src/context/sdk.tsx +48 -0
- package/src/context/server.tsx +214 -0
- package/src/context/settings.tsx +166 -0
- package/src/context/sync.tsx +320 -0
- package/src/context/terminal.tsx +267 -0
- package/src/custom-elements.d.ts +17 -0
- package/src/entry.tsx +76 -0
- package/src/env.d.ts +8 -0
- package/src/hooks/use-providers.ts +31 -0
- package/src/i18n/ar.ts +656 -0
- package/src/i18n/br.ts +667 -0
- package/src/i18n/da.ts +582 -0
- package/src/i18n/de.ts +591 -0
- package/src/i18n/en.ts +665 -0
- package/src/i18n/es.ts +585 -0
- package/src/i18n/fr.ts +592 -0
- package/src/i18n/ja.ts +579 -0
- package/src/i18n/ko.ts +580 -0
- package/src/i18n/no.ts +602 -0
- package/src/i18n/pl.ts +661 -0
- package/src/i18n/ru.ts +664 -0
- package/src/i18n/zh.ts +574 -0
- package/src/i18n/zht.ts +570 -0
- package/src/index.css +57 -0
- package/src/index.ts +2 -0
- package/src/pages/directory-layout.tsx +57 -0
- package/src/pages/error.tsx +290 -0
- package/src/pages/home.tsx +125 -0
- package/src/pages/layout.tsx +2599 -0
- package/src/pages/session.tsx +2505 -0
- package/src/sst-env.d.ts +10 -0
- package/src/utils/dom.ts +51 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/perf.ts +135 -0
- package/src/utils/persist.ts +377 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/solid-dnd.tsx +55 -0
- package/src/utils/sound.ts +110 -0
- package/src/utils/speech.ts +302 -0
- package/src/utils/worktree.ts +58 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- 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
|
+
}
|