summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/src/components/prompt-input.tsx844
1 files changed, 440 insertions, 404 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 28d49b721..a5edac980 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -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>