|
|
@@ -448,32 +448,59 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
onSelect: handleSlashSelect,
|
|
|
})
|
|
|
|
|
|
+ const createPill = (part: FileAttachmentPart | AgentPart) => {
|
|
|
+ const pill = document.createElement("span")
|
|
|
+ pill.textContent = part.content
|
|
|
+ pill.setAttribute("data-type", part.type)
|
|
|
+ if (part.type === "file") pill.setAttribute("data-path", part.path)
|
|
|
+ if (part.type === "agent") pill.setAttribute("data-name", part.name)
|
|
|
+ pill.setAttribute("contenteditable", "false")
|
|
|
+ pill.style.userSelect = "text"
|
|
|
+ pill.style.cursor = "default"
|
|
|
+ return pill
|
|
|
+ }
|
|
|
+
|
|
|
+ const isNormalizedEditor = () =>
|
|
|
+ Array.from(editorRef.childNodes).every((node) => {
|
|
|
+ if (node.nodeType === Node.TEXT_NODE) {
|
|
|
+ const text = node.textContent ?? ""
|
|
|
+ if (!text.includes("\u200B")) return true
|
|
|
+ if (text !== "\u200B") return false
|
|
|
+
|
|
|
+ const prev = node.previousSibling
|
|
|
+ const next = node.nextSibling
|
|
|
+ const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
|
|
+ const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
|
|
+ if (!prevIsBr && !nextIsBr) return false
|
|
|
+ if (nextIsBr && !prevIsBr && prev) return false
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ if (node.nodeType !== Node.ELEMENT_NODE) return false
|
|
|
+ const el = node as HTMLElement
|
|
|
+ if (el.dataset.type === "file") return true
|
|
|
+ if (el.dataset.type === "agent") return true
|
|
|
+ return el.tagName === "BR"
|
|
|
+ })
|
|
|
+
|
|
|
+ const renderEditor = (parts: Prompt) => {
|
|
|
+ editorRef.innerHTML = ""
|
|
|
+ for (const part of parts) {
|
|
|
+ if (part.type === "text") {
|
|
|
+ editorRef.appendChild(createTextFragment(part.content))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (part.type === "file" || part.type === "agent") {
|
|
|
+ editorRef.appendChild(createPill(part))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
createEffect(
|
|
|
on(
|
|
|
() => prompt.current(),
|
|
|
(currentParts) => {
|
|
|
const domParts = parseFromDOM()
|
|
|
- const normalized = Array.from(editorRef.childNodes).every((node) => {
|
|
|
- if (node.nodeType === Node.TEXT_NODE) {
|
|
|
- const text = node.textContent ?? ""
|
|
|
- if (!text.includes("\u200B")) return true
|
|
|
- if (text !== "\u200B") return false
|
|
|
-
|
|
|
- const prev = node.previousSibling
|
|
|
- const next = node.nextSibling
|
|
|
- const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
|
|
- const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
|
|
- if (!prevIsBr && !nextIsBr) return false
|
|
|
- if (nextIsBr && !prevIsBr && prev) return false
|
|
|
- return true
|
|
|
- }
|
|
|
- if (node.nodeType !== Node.ELEMENT_NODE) return false
|
|
|
- const el = node as HTMLElement
|
|
|
- if (el.dataset.type === "file") return true
|
|
|
- if (el.dataset.type === "agent") return true
|
|
|
- return el.tagName === "BR"
|
|
|
- })
|
|
|
- if (normalized && isPromptEqual(currentParts, domParts)) return
|
|
|
+ if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
|
|
|
|
|
|
const selection = window.getSelection()
|
|
|
let cursorPosition: number | null = null
|
|
|
@@ -481,30 +508,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
cursorPosition = getCursorPosition(editorRef)
|
|
|
}
|
|
|
|
|
|
- editorRef.innerHTML = ""
|
|
|
- currentParts.forEach((part) => {
|
|
|
- if (part.type === "text") {
|
|
|
- editorRef.appendChild(createTextFragment(part.content))
|
|
|
- } else if (part.type === "file") {
|
|
|
- const pill = document.createElement("span")
|
|
|
- pill.textContent = part.content
|
|
|
- pill.setAttribute("data-type", "file")
|
|
|
- pill.setAttribute("data-path", part.path)
|
|
|
- pill.setAttribute("contenteditable", "false")
|
|
|
- pill.style.userSelect = "text"
|
|
|
- pill.style.cursor = "default"
|
|
|
- editorRef.appendChild(pill)
|
|
|
- } else if (part.type === "agent") {
|
|
|
- const pill = document.createElement("span")
|
|
|
- pill.textContent = part.content
|
|
|
- pill.setAttribute("data-type", "agent")
|
|
|
- pill.setAttribute("data-name", part.name)
|
|
|
- pill.setAttribute("contenteditable", "false")
|
|
|
- pill.style.userSelect = "text"
|
|
|
- pill.style.cursor = "default"
|
|
|
- editorRef.appendChild(pill)
|
|
|
- }
|
|
|
- })
|
|
|
+ renderEditor(currentParts)
|
|
|
|
|
|
if (cursorPosition !== null) {
|
|
|
setCursorPosition(editorRef, cursorPosition)
|
|
|
@@ -682,40 +686,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
|
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
|
|
|
|
|
- if (part.type === "file") {
|
|
|
- const pill = document.createElement("span")
|
|
|
- pill.textContent = part.content
|
|
|
- pill.setAttribute("data-type", "file")
|
|
|
- pill.setAttribute("data-path", part.path)
|
|
|
- pill.setAttribute("contenteditable", "false")
|
|
|
- pill.style.userSelect = "text"
|
|
|
- pill.style.cursor = "default"
|
|
|
-
|
|
|
- const gap = document.createTextNode(" ")
|
|
|
- const range = selection.getRangeAt(0)
|
|
|
-
|
|
|
- if (atMatch) {
|
|
|
- const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
|
|
- setRangeEdge(range, "start", start)
|
|
|
- setRangeEdge(range, "end", cursorPosition)
|
|
|
- }
|
|
|
-
|
|
|
- range.deleteContents()
|
|
|
- range.insertNode(gap)
|
|
|
- range.insertNode(pill)
|
|
|
- range.setStartAfter(gap)
|
|
|
- range.collapse(true)
|
|
|
- selection.removeAllRanges()
|
|
|
- selection.addRange(range)
|
|
|
- } else if (part.type === "agent") {
|
|
|
- const pill = document.createElement("span")
|
|
|
- pill.textContent = part.content
|
|
|
- pill.setAttribute("data-type", "agent")
|
|
|
- pill.setAttribute("data-name", part.name)
|
|
|
- pill.setAttribute("contenteditable", "false")
|
|
|
- pill.style.userSelect = "text"
|
|
|
- pill.style.cursor = "default"
|
|
|
-
|
|
|
+ if (part.type === "file" || part.type === "agent") {
|
|
|
+ const pill = createPill(part)
|
|
|
const gap = document.createTextNode(" ")
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
|
|
@@ -761,77 +733,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
- const setSelectionOffsets = (start: number, end: number) => {
|
|
|
- const selection = window.getSelection()
|
|
|
- if (!selection) return false
|
|
|
-
|
|
|
- const length = promptLength(prompt.current())
|
|
|
- const a = Math.max(0, Math.min(start, length))
|
|
|
- const b = Math.max(0, Math.min(end, length))
|
|
|
- const rangeStart = Math.min(a, b)
|
|
|
- const rangeEnd = Math.max(a, b)
|
|
|
-
|
|
|
- const range = document.createRange()
|
|
|
- range.selectNodeContents(editorRef)
|
|
|
-
|
|
|
- const setEdge = (edge: "start" | "end", offset: number) => {
|
|
|
- let remaining = offset
|
|
|
- const nodes = Array.from(editorRef.childNodes)
|
|
|
-
|
|
|
- for (const node of nodes) {
|
|
|
- const length = getNodeLength(node)
|
|
|
- const isText = node.nodeType === Node.TEXT_NODE
|
|
|
- const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
|
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
|
|
-
|
|
|
- if (isText && remaining <= length) {
|
|
|
- if (edge === "start") range.setStart(node, remaining)
|
|
|
- if (edge === "end") range.setEnd(node, remaining)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if ((isFile || isBreak) && remaining <= length) {
|
|
|
- if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
|
|
- if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
|
|
- if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
|
|
- if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- remaining -= length
|
|
|
- }
|
|
|
-
|
|
|
- const last = editorRef.lastChild
|
|
|
- if (!last) {
|
|
|
- if (edge === "start") range.setStart(editorRef, 0)
|
|
|
- if (edge === "end") range.setEnd(editorRef, 0)
|
|
|
- return
|
|
|
- }
|
|
|
- if (edge === "start") range.setStartAfter(last)
|
|
|
- if (edge === "end") range.setEndAfter(last)
|
|
|
- }
|
|
|
-
|
|
|
- setEdge("start", rangeStart)
|
|
|
- setEdge("end", rangeEnd)
|
|
|
- selection.removeAllRanges()
|
|
|
- selection.addRange(range)
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- const replaceOffsets = (start: number, end: number, content: string) => {
|
|
|
- if (!setSelectionOffsets(start, end)) return false
|
|
|
- addPart({ type: "text", content, start: 0, end: 0 })
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- const killText = (start: number, end: number) => {
|
|
|
- if (start === end) return
|
|
|
- const current = prompt.current()
|
|
|
- if (!current.every((part) => part.type === "text")) return
|
|
|
- const text = current.map((part) => part.content).join("")
|
|
|
- setStore("killBuffer", text.slice(start, end))
|
|
|
- }
|
|
|
-
|
|
|
const abort = () =>
|
|
|
sdk.client.session
|
|
|
.abort({
|
|
|
@@ -900,6 +801,242 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
+ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
|
|
+ const IS_WIN = typeof navigator === "object" && /Win/.test(navigator.platform)
|
|
|
+
|
|
|
+ const textOnly = () => {
|
|
|
+ const parts = prompt.current()
|
|
|
+ if (!parts.every((part) => part.type === "text")) return
|
|
|
+ return parts.map((part) => part.content).join("")
|
|
|
+ }
|
|
|
+
|
|
|
+ const exitHistoryMode = () => {
|
|
|
+ if (store.historyIndex < 0) return
|
|
|
+ if (store.applyingHistory) return
|
|
|
+ setStore("historyIndex", -1)
|
|
|
+ setStore("savedPrompt", null)
|
|
|
+ }
|
|
|
+
|
|
|
+ const applyText = (content: string, cursorPosition: number) => {
|
|
|
+ exitHistoryMode()
|
|
|
+ setStore("popover", null)
|
|
|
+
|
|
|
+ const part = {
|
|
|
+ type: "text" as const,
|
|
|
+ content,
|
|
|
+ start: 0,
|
|
|
+ end: content.length,
|
|
|
+ }
|
|
|
+
|
|
|
+ prompt.set([part], cursorPosition)
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ editorRef.focus()
|
|
|
+ setCursorPosition(editorRef, cursorPosition)
|
|
|
+ queueScroll()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleReadlineKeyDown = (event: KeyboardEvent) => {
|
|
|
+ if (event.metaKey) return false
|
|
|
+
|
|
|
+ const ctrl = event.ctrlKey && !event.altKey && !event.shiftKey
|
|
|
+ const alt = event.altKey && !event.ctrlKey && !event.shiftKey
|
|
|
+
|
|
|
+ if (!ctrl && !alt) return false
|
|
|
+
|
|
|
+ if (alt && IS_WIN) return false
|
|
|
+
|
|
|
+ if (ctrl && IS_WIN) {
|
|
|
+ const blocked = new Set(["KeyA", "KeyC", "KeyV", "KeyX", "KeyZ", "KeyY", "KeyF", "KeyT"])
|
|
|
+ if (blocked.has(event.code)) return false
|
|
|
+ }
|
|
|
+
|
|
|
+ const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
|
+ if (!collapsed) return false
|
|
|
+
|
|
|
+ const text = textOnly()
|
|
|
+ if (text === undefined) return false
|
|
|
+
|
|
|
+ const moveCursor = (pos: number) => {
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ queueScroll()
|
|
|
+ }
|
|
|
+
|
|
|
+ const saveKillBuffer = (start: number, end: number) => {
|
|
|
+ if (start === end) return false
|
|
|
+ setStore("killBuffer", text.slice(start, end))
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const killRange = (start: number, end: number) => {
|
|
|
+ if (!saveKillBuffer(start, end)) return
|
|
|
+ applyText(text.slice(0, start) + text.slice(end), start)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ctrl) {
|
|
|
+ if (event.code === "KeyA" && IS_MAC) {
|
|
|
+ const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyE") {
|
|
|
+ const next = text.indexOf("\n", cursorPosition)
|
|
|
+ const pos = next === -1 ? textLength : next
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyB") {
|
|
|
+ const pos = Math.max(0, cursorPosition - 1)
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyF" && IS_MAC) {
|
|
|
+ const pos = Math.min(textLength, cursorPosition + 1)
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyD") {
|
|
|
+ if (cursorPosition >= textLength) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ applyText(text.slice(0, cursorPosition) + text.slice(cursorPosition + 1), cursorPosition)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyK") {
|
|
|
+ const next = text.indexOf("\n", cursorPosition)
|
|
|
+ const lineEnd = next === -1 ? textLength : next
|
|
|
+ const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
|
|
|
+ if (end === cursorPosition) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ killRange(cursorPosition, end)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyU" && IS_MAC) {
|
|
|
+ const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
+ if (start === cursorPosition) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ killRange(start, cursorPosition)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyY" && IS_MAC) {
|
|
|
+ if (!store.killBuffer) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ applyText(
|
|
|
+ text.slice(0, cursorPosition) + store.killBuffer + text.slice(cursorPosition),
|
|
|
+ cursorPosition + store.killBuffer.length,
|
|
|
+ )
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyT" && IS_MAC) {
|
|
|
+ if (textLength < 2 || cursorPosition === 0) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const atEnd = cursorPosition === textLength
|
|
|
+ const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
|
|
|
+ const second = atEnd ? cursorPosition - 1 : cursorPosition
|
|
|
+
|
|
|
+ if (text[first] === "\n" || text[second] === "\n") {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextText = text.slice(0, first) + text[second] + text[first] + text.slice(second + 1)
|
|
|
+ const nextCursor = atEnd ? cursorPosition : cursorPosition + 1
|
|
|
+ applyText(nextText, nextCursor)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (alt) {
|
|
|
+ if (event.code === "KeyB") {
|
|
|
+ let pos = cursorPosition
|
|
|
+ while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
|
|
|
+ while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyF") {
|
|
|
+ let pos = cursorPosition
|
|
|
+ while (pos < textLength && /\s/.test(text[pos])) pos += 1
|
|
|
+ while (pos < textLength && !/\s/.test(text[pos])) pos += 1
|
|
|
+ moveCursor(pos)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyD") {
|
|
|
+ let end = cursorPosition
|
|
|
+ while (end < textLength && /\s/.test(text[end])) end += 1
|
|
|
+ while (end < textLength && !/\s/.test(text[end])) end += 1
|
|
|
+ if (end === cursorPosition) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ killRange(cursorPosition, end)
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
if (event.key === "Backspace") {
|
|
|
const selection = window.getSelection()
|
|
|
@@ -953,7 +1090,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
|
|
|
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
|
|
- const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
|
|
|
|
|
|
if (ctrl && event.code === "KeyG") {
|
|
|
if (store.popover) {
|
|
|
@@ -968,148 +1104,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if (ctrl || alt) {
|
|
|
- const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
|
- if (collapsed) {
|
|
|
- const current = prompt.current()
|
|
|
- const text = current.map((part) => ("content" in part ? part.content : "")).join("")
|
|
|
-
|
|
|
- if (ctrl) {
|
|
|
- if (event.code === "KeyA") {
|
|
|
- if (navigator.platform.includes("Win")) return
|
|
|
- const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyE") {
|
|
|
- const next = text.indexOf("\n", cursorPosition)
|
|
|
- const pos = next === -1 ? textLength : next
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyB") {
|
|
|
- const pos = Math.max(0, cursorPosition - 1)
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyF") {
|
|
|
- const pos = Math.min(textLength, cursorPosition + 1)
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyD") {
|
|
|
- if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
|
|
|
- setStore("mode", "normal")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- if (cursorPosition >= textLength) return
|
|
|
- replaceOffsets(cursorPosition, cursorPosition + 1, "")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyK") {
|
|
|
- const next = text.indexOf("\n", cursorPosition)
|
|
|
- const lineEnd = next === -1 ? textLength : next
|
|
|
- const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
|
|
|
- if (end === cursorPosition) return
|
|
|
- killText(cursorPosition, end)
|
|
|
- replaceOffsets(cursorPosition, end, "")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyU") {
|
|
|
- const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
- if (start === cursorPosition) return
|
|
|
- killText(start, cursorPosition)
|
|
|
- replaceOffsets(start, cursorPosition, "")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyW") {
|
|
|
- let start = cursorPosition
|
|
|
- while (start > 0 && /\s/.test(text[start - 1])) start -= 1
|
|
|
- while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
|
|
|
- if (start === cursorPosition) return
|
|
|
- killText(start, cursorPosition)
|
|
|
- replaceOffsets(start, cursorPosition, "")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyY") {
|
|
|
- if (!store.killBuffer) return
|
|
|
- addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyT") {
|
|
|
- if (!current.every((part) => part.type === "text")) return
|
|
|
- if (textLength < 2) return
|
|
|
- if (cursorPosition === 0) return
|
|
|
-
|
|
|
- const atEnd = cursorPosition === textLength
|
|
|
- const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
|
|
|
- const second = atEnd ? cursorPosition - 1 : cursorPosition
|
|
|
-
|
|
|
- if (text[first] === "\n" || text[second] === "\n") return
|
|
|
-
|
|
|
- replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (alt) {
|
|
|
- if (event.code === "KeyB") {
|
|
|
- let pos = cursorPosition
|
|
|
- while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
|
|
|
- while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyF") {
|
|
|
- let pos = cursorPosition
|
|
|
- while (pos < textLength && /\s/.test(text[pos])) pos += 1
|
|
|
- while (pos < textLength && !/\s/.test(text[pos])) pos += 1
|
|
|
- setCursorPosition(editorRef, pos)
|
|
|
- event.preventDefault()
|
|
|
- queueScroll()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (event.code === "KeyD") {
|
|
|
- let end = cursorPosition
|
|
|
- while (end < textLength && /\s/.test(text[end])) end += 1
|
|
|
- while (end < textLength && !/\s/.test(text[end])) end += 1
|
|
|
- if (end === cursorPosition) return
|
|
|
- killText(cursorPosition, end)
|
|
|
- replaceOffsets(cursorPosition, end, "")
|
|
|
- event.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ if (handleReadlineKeyDown(event)) return
|
|
|
|
|
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
|
@@ -1164,15 +1159,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
const handleSubmit = async (event: Event) => {
|
|
|
event.preventDefault()
|
|
|
+
|
|
|
const currentPrompt = prompt.current()
|
|
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
|
|
- const hasImageAttachments = store.imageAttachments.length > 0
|
|
|
- if (text.trim().length === 0 && !hasImageAttachments) {
|
|
|
+ const images = store.imageAttachments.slice()
|
|
|
+ const mode = store.mode
|
|
|
+
|
|
|
+ if (text.trim().length === 0 && images.length === 0) {
|
|
|
if (working()) abort()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- addToHistory(currentPrompt, store.mode)
|
|
|
+ const currentModel = local.model.current()
|
|
|
+ const currentAgent = local.agent.current()
|
|
|
+ if (!currentModel || !currentAgent) {
|
|
|
+ showToast({
|
|
|
+ title: "Select an agent and model",
|
|
|
+ description: "Choose an agent and model before sending a prompt.",
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const errorMessage = (err: unknown) => {
|
|
|
+ if (err && typeof err === "object" && "data" in err) {
|
|
|
+ const data = (err as { data?: { message?: string } }).data
|
|
|
+ if (data?.message) return data.message
|
|
|
+ }
|
|
|
+ if (err instanceof Error) return err.message
|
|
|
+ return "Request failed"
|
|
|
+ }
|
|
|
+
|
|
|
+ addToHistory(currentPrompt, mode)
|
|
|
setStore("historyIndex", -1)
|
|
|
setStore("savedPrompt", null)
|
|
|
|
|
|
@@ -1191,7 +1208,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
.catch((err) => {
|
|
|
showToast({
|
|
|
title: "Failed to create worktree",
|
|
|
- description: err?.data?.message ?? (err instanceof Error ? err.message : "Request failed"),
|
|
|
+ description: errorMessage(err),
|
|
|
})
|
|
|
return undefined
|
|
|
})
|
|
|
@@ -1204,7 +1221,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
return
|
|
|
}
|
|
|
sessionDirectory = createdWorktree.directory
|
|
|
- } else if (worktreeSelection !== "main") {
|
|
|
+ }
|
|
|
+
|
|
|
+ if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
|
|
sessionDirectory = worktreeSelection
|
|
|
}
|
|
|
|
|
|
@@ -1215,26 +1234,93 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
directory: sessionDirectory,
|
|
|
throwOnError: true,
|
|
|
})
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (isNewSession) {
|
|
|
- if (sessionDirectory !== projectDirectory) {
|
|
|
globalSync.child(sessionDirectory)
|
|
|
}
|
|
|
+
|
|
|
props.onNewSessionWorktreeReset?.()
|
|
|
}
|
|
|
|
|
|
- let existing = info()
|
|
|
- if (!existing && isNewSession) {
|
|
|
- const created = await client.session.create()
|
|
|
- existing = created.data ?? undefined
|
|
|
- if (existing) navigate(`/${base64Encode(sessionDirectory)}/session/${existing.id}`)
|
|
|
+ let session = info()
|
|
|
+ if (!session && isNewSession) {
|
|
|
+ session = await client.session.create().then((x) => x.data ?? undefined)
|
|
|
+ if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
|
+ }
|
|
|
+ if (!session) return
|
|
|
+
|
|
|
+ const model = {
|
|
|
+ modelID: currentModel.id,
|
|
|
+ providerID: currentModel.provider.id,
|
|
|
+ }
|
|
|
+ const agent = currentAgent.name
|
|
|
+ const variant = local.model.variant.current()
|
|
|
+
|
|
|
+ const clearInput = () => {
|
|
|
+ prompt.reset()
|
|
|
+ setStore("imageAttachments", [])
|
|
|
+ setStore("mode", "normal")
|
|
|
+ setStore("popover", null)
|
|
|
+ }
|
|
|
+
|
|
|
+ const restoreInput = () => {
|
|
|
+ prompt.set(currentPrompt, promptLength(currentPrompt))
|
|
|
+ setStore("imageAttachments", images)
|
|
|
+ setStore("mode", mode)
|
|
|
+ setStore("popover", null)
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ editorRef.focus()
|
|
|
+ setCursorPosition(editorRef, promptLength(currentPrompt))
|
|
|
+ queueScroll()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mode === "shell") {
|
|
|
+ clearInput()
|
|
|
+ client.session
|
|
|
+ .shell({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent,
|
|
|
+ model,
|
|
|
+ command: text,
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ showToast({
|
|
|
+ title: "Failed to send shell command",
|
|
|
+ description: errorMessage(err),
|
|
|
+ })
|
|
|
+ restoreInput()
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (text.startsWith("/")) {
|
|
|
+ const [cmdName, ...args] = text.split(" ")
|
|
|
+ const commandName = cmdName.slice(1)
|
|
|
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
|
|
|
+ if (customCommand) {
|
|
|
+ clearInput()
|
|
|
+ client.session
|
|
|
+ .command({
|
|
|
+ sessionID: session.id,
|
|
|
+ command: commandName,
|
|
|
+ arguments: args.join(" "),
|
|
|
+ agent,
|
|
|
+ model: `${model.providerID}/${model.modelID}`,
|
|
|
+ variant,
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ showToast({
|
|
|
+ title: "Failed to send command",
|
|
|
+ description: errorMessage(err),
|
|
|
+ })
|
|
|
+ restoreInput()
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
}
|
|
|
- if (!existing) return
|
|
|
|
|
|
const toAbsolutePath = (path: string) =>
|
|
|
path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
|
|
|
+
|
|
|
const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
|
|
|
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
|
|
|
|
|
|
@@ -1307,7 +1393,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
addContextFile(item.path, item.selection)
|
|
|
}
|
|
|
|
|
|
- const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
|
|
+ const imageAttachmentParts = images.map((attachment) => ({
|
|
|
id: Identifier.ascending("part"),
|
|
|
type: "file" as const,
|
|
|
mime: attachment.mime,
|
|
|
@@ -1315,60 +1401,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
filename: attachment.filename,
|
|
|
}))
|
|
|
|
|
|
- const isShellMode = store.mode === "shell"
|
|
|
- editorRef.innerHTML = ""
|
|
|
- prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
|
|
- setStore("imageAttachments", [])
|
|
|
- setStore("mode", "normal")
|
|
|
-
|
|
|
- const currentModel = local.model.current()
|
|
|
- const currentAgent = local.agent.current()
|
|
|
- if (!currentModel || !currentAgent) {
|
|
|
- console.warn("No agent or model available for prompt submission")
|
|
|
- return
|
|
|
- }
|
|
|
- const model = {
|
|
|
- modelID: currentModel.id,
|
|
|
- providerID: currentModel.provider.id,
|
|
|
- }
|
|
|
- const agent = currentAgent.name
|
|
|
- const variant = local.model.variant.current()
|
|
|
-
|
|
|
- if (isShellMode) {
|
|
|
- client.session
|
|
|
- .shell({
|
|
|
- sessionID: existing.id,
|
|
|
- agent,
|
|
|
- model,
|
|
|
- command: text,
|
|
|
- })
|
|
|
- .catch((e) => {
|
|
|
- console.error("Failed to send shell command", e)
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (text.startsWith("/")) {
|
|
|
- const [cmdName, ...args] = text.split(" ")
|
|
|
- const commandName = cmdName.slice(1)
|
|
|
- const customCommand = sync.data.command.find((c) => c.name === commandName)
|
|
|
- if (customCommand) {
|
|
|
- client.session
|
|
|
- .command({
|
|
|
- sessionID: existing.id,
|
|
|
- command: commandName,
|
|
|
- arguments: args.join(" "),
|
|
|
- agent,
|
|
|
- model: `${model.providerID}/${model.modelID}`,
|
|
|
- variant,
|
|
|
- })
|
|
|
- .catch((e) => {
|
|
|
- console.error("Failed to send command", e)
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
const messageID = Identifier.ascending("message")
|
|
|
const textPart = {
|
|
|
id: Identifier.ascending("part"),
|
|
|
@@ -1382,44 +1414,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
...agentAttachmentParts,
|
|
|
...imageAttachmentParts,
|
|
|
]
|
|
|
+
|
|
|
const optimisticParts = requestParts.map((part) => ({
|
|
|
...part,
|
|
|
- sessionID: existing.id,
|
|
|
+ sessionID: session.id,
|
|
|
messageID,
|
|
|
- }))
|
|
|
+ })) as unknown as Part[]
|
|
|
|
|
|
- const addOptimisticMessage = (input: {
|
|
|
- sessionID: string
|
|
|
- messageID: string
|
|
|
- parts: Part[]
|
|
|
- agent: string
|
|
|
- model: { providerID: string; modelID: string }
|
|
|
- }) => {
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.session.addOptimisticMessage(input)
|
|
|
- return
|
|
|
- }
|
|
|
+ const optimisticMessage: Message = {
|
|
|
+ id: messageID,
|
|
|
+ sessionID: session.id,
|
|
|
+ role: "user",
|
|
|
+ time: { created: Date.now() },
|
|
|
+ agent,
|
|
|
+ model,
|
|
|
+ }
|
|
|
|
|
|
- const [, setStore] = globalSync.child(sessionDirectory)
|
|
|
- const message: Message = {
|
|
|
- id: input.messageID,
|
|
|
- sessionID: input.sessionID,
|
|
|
- role: "user",
|
|
|
- time: { created: Date.now() },
|
|
|
- agent: input.agent,
|
|
|
- model: input.model,
|
|
|
- }
|
|
|
+ const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
|
|
|
|
|
|
- setStore(
|
|
|
+ const addOptimisticMessage = () => {
|
|
|
+ setSyncStore(
|
|
|
produce((draft) => {
|
|
|
- const messages = draft.message[input.sessionID]
|
|
|
+ const messages = draft.message[session.id]
|
|
|
if (!messages) {
|
|
|
- draft.message[input.sessionID] = [message]
|
|
|
+ draft.message[session.id] = [optimisticMessage]
|
|
|
} else {
|
|
|
- const result = Binary.search(messages, input.messageID, (m) => m.id)
|
|
|
- messages.splice(result.index, 0, message)
|
|
|
+ const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
+ messages.splice(result.index, 0, optimisticMessage)
|
|
|
}
|
|
|
- draft.part[input.messageID] = input.parts
|
|
|
+ draft.part[messageID] = optimisticParts
|
|
|
.filter((p) => !!p?.id)
|
|
|
.slice()
|
|
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
|
@@ -1427,25 +1450,38 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- addOptimisticMessage({
|
|
|
- sessionID: existing.id,
|
|
|
- messageID,
|
|
|
- parts: optimisticParts as unknown as Part[],
|
|
|
- agent,
|
|
|
- model,
|
|
|
- })
|
|
|
+ const removeOptimisticMessage = () => {
|
|
|
+ setSyncStore(
|
|
|
+ produce((draft) => {
|
|
|
+ const messages = draft.message[session.id]
|
|
|
+ if (messages) {
|
|
|
+ const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
+ if (result.found) messages.splice(result.index, 1)
|
|
|
+ }
|
|
|
+ delete draft.part[messageID]
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ clearInput()
|
|
|
+ addOptimisticMessage()
|
|
|
|
|
|
client.session
|
|
|
.prompt({
|
|
|
- sessionID: existing.id,
|
|
|
+ sessionID: session.id,
|
|
|
agent,
|
|
|
model,
|
|
|
messageID,
|
|
|
parts: requestParts,
|
|
|
variant,
|
|
|
})
|
|
|
- .catch((e) => {
|
|
|
- console.error("Failed to send prompt", e)
|
|
|
+ .catch((err) => {
|
|
|
+ showToast({
|
|
|
+ title: "Failed to send prompt",
|
|
|
+ description: errorMessage(err),
|
|
|
+ })
|
|
|
+ removeOptimisticMessage()
|
|
|
+ restoreInput()
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -1764,7 +1800,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
<input
|
|
|
ref={fileInputRef}
|
|
|
type="file"
|
|
|
- accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
|
|
+ accept={ACCEPTED_FILE_TYPES.join(",")}
|
|
|
class="hidden"
|
|
|
onChange={(e) => {
|
|
|
const file = e.currentTarget.files?.[0]
|
|
|
@@ -1775,7 +1811,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
<div class="flex items-center gap-2">
|
|
|
<SessionContextUsage />
|
|
|
<Show when={store.mode === "normal"}>
|
|
|
- <Tooltip placement="top" value="Attach image">
|
|
|
+ <Tooltip placement="top" value="Attach file">
|
|
|
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
|
|
|
<Icon name="photo" class="size-4.5" />
|
|
|
</Button>
|