refactor(ui): split prompt input into hooks and API
Extract prompt draft/history, attachments, picker, and keydown logic into co-located hooks. Introduce PromptInputApi for quote/expand/setText and migrate SessionView off DOM poking; remove legacy registerQuoteHandler.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
interface PromptAttachmentsBarProps {
|
||||
attachments: Attachment[]
|
||||
onRemoveAttachment: (attachmentId: string) => void
|
||||
onExpandTextAttachment: (attachmentId: string) => void
|
||||
}
|
||||
|
||||
const PromptAttachmentsBar: Component<PromptAttachmentsBarProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => props.onExpandTextAttachment(attachment.id)}
|
||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => props.onRemoveAttachment(attachment.id)}
|
||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptAttachmentsBar
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
|
||||
export function formatPastedPlaceholder(value: string | number) {
|
||||
return `[pasted #${value}]`
|
||||
}
|
||||
|
||||
export function formatImagePlaceholder(value: string | number) {
|
||||
return `[Image #${value}]`
|
||||
}
|
||||
|
||||
export function createPastedPlaceholderRegex() {
|
||||
return /\[pasted #(\d+)\]/g
|
||||
}
|
||||
|
||||
export function createImagePlaceholderRegex() {
|
||||
return /\[Image #(\d+)\]/g
|
||||
}
|
||||
|
||||
export function createMentionRegex() {
|
||||
return /@(\S+)/g
|
||||
}
|
||||
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/
|
||||
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
||||
|
||||
export function parseCounter(value: string) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
let highestPaste = 0
|
||||
let highestImage = 0
|
||||
|
||||
for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
highestPaste = Math.max(highestPaste, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of sessionAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const parsed = parseCounter(placeholderMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestPaste = Math.max(highestPaste, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const parsed = parseCounter(imageMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestImage = Math.max(highestImage, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
highestImage = Math.max(highestImage, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return { highestPaste, highestImage }
|
||||
}
|
||||
26
packages/ui/src/components/prompt-input/types.ts
Normal file
26
packages/ui/src/components/prompt-input/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
|
||||
export type PromptMode = "normal" | "shell"
|
||||
export type ExpandState = "normal" | "expanded"
|
||||
export type PickerMode = "mention" | "command"
|
||||
export type PromptInsertMode = "quote" | "code"
|
||||
|
||||
export interface PromptInputApi {
|
||||
insertSelection(text: string, mode: PromptInsertMode): void
|
||||
expandTextAttachment(attachmentId: string): void
|
||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
escapeInDebounce?: boolean
|
||||
isSessionBusy?: boolean
|
||||
onAbortSession?: () => Promise<void>
|
||||
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
|
||||
}
|
||||
296
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
296
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import {
|
||||
bracketedImageDisplayCounterRegex,
|
||||
findHighestAttachmentCounters,
|
||||
formatImagePlaceholder,
|
||||
formatPastedPlaceholder,
|
||||
pastedDisplayCounterRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
type PromptAttachmentsOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
}
|
||||
|
||||
type PromptAttachments = {
|
||||
attachments: Accessor<Attachment[]>
|
||||
pasteCount: Accessor<number>
|
||||
imageCount: Accessor<number>
|
||||
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
||||
|
||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||
isDragging: Accessor<boolean>
|
||||
handleDragOver: (e: DragEvent) => void
|
||||
handleDragLeave: (e: DragEvent) => void
|
||||
handleDrop: (e: DragEvent) => void
|
||||
|
||||
handleRemoveAttachment: (attachmentId: string) => void
|
||||
handleExpandTextAttachment: (attachment: Attachment) => void
|
||||
}
|
||||
|
||||
export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments {
|
||||
const attachments = () => getAttachments(options.instanceId(), options.sessionId())
|
||||
const [isDragging, setIsDragging] = createSignal(false)
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
||||
setPasteCount(highestPaste)
|
||||
setImageCount(highestImage)
|
||||
}
|
||||
|
||||
function handleRemoveAttachment(attachmentId: string) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
|
||||
if (attachment) {
|
||||
const currentPrompt = options.prompt()
|
||||
let newPrompt = currentPrompt
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const placeholder = formatImagePlaceholder(imageMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else {
|
||||
const filename = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
}
|
||||
|
||||
options.setPrompt(newPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpandTextAttachment(attachment: Attachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||
const placeholder = match ? formatPastedPlaceholder(match[1]) : null
|
||||
const currentText = options.prompt()
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
options.setPrompt(nextText)
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachment.id)
|
||||
|
||||
if (textarea) {
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault()
|
||||
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
|
||||
const count = imageCount() + 1
|
||||
setImageCount(count)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64Data = (reader.result as string).split(",")[1]
|
||||
const display = formatImagePlaceholder(count)
|
||||
const filename = `image-${count}.png`
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
filename,
|
||||
filename,
|
||||
"image/png",
|
||||
new TextEncoder().encode(base64Data),
|
||||
options.instanceFolder(),
|
||||
)
|
||||
attachment.url = `data:image/png;base64,${base64Data}`
|
||||
attachment.display = display
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const pastedText = e.clipboardData?.getData("text/plain")
|
||||
if (!pastedText) return
|
||||
|
||||
const lineCount = pastedText.split("\n").length
|
||||
const charCount = pastedText.length
|
||||
|
||||
const isLongPaste = charCount > 150 || lineCount > 3
|
||||
|
||||
if (isLongPaste) {
|
||||
e.preventDefault()
|
||||
|
||||
const count = pasteCount() + 1
|
||||
setPasteCount(count)
|
||||
|
||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||
const display = `pasted #${count} (${summary})`
|
||||
const filename = `paste-${count}.txt`
|
||||
|
||||
const attachment = createTextAttachment(pastedText, display, filename)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const path = (file as File & { path?: string }).path || file.name
|
||||
const filename = file.name
|
||||
const mime = file.type || "text/plain"
|
||||
|
||||
const createAndStoreAttachment = (previewUrl?: string) => {
|
||||
const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder())
|
||||
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
|
||||
attachment.url = previewUrl
|
||||
}
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(dataUrl)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
createAndStoreAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
options.getTextarea()?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
attachments,
|
||||
pasteCount,
|
||||
imageCount,
|
||||
syncAttachmentCounters,
|
||||
handlePaste,
|
||||
isDragging,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleRemoveAttachment,
|
||||
handleExpandTextAttachment,
|
||||
}
|
||||
}
|
||||
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { PromptMode } from "./types"
|
||||
import {
|
||||
createImagePlaceholderRegex,
|
||||
createMentionRegex,
|
||||
createPastedPlaceholderRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
export type UsePromptKeyDownOptions = {
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (v: string) => void
|
||||
|
||||
mode: Accessor<PromptMode>
|
||||
setMode: (m: PromptMode) => void
|
||||
|
||||
isPickerOpen: Accessor<boolean>
|
||||
closePicker: () => void
|
||||
|
||||
ignoredAtPositions: Accessor<Set<number>>
|
||||
setIgnoredAtPositions: (next: Set<number> | ((s: Set<number>) => Set<number>)) => void
|
||||
|
||||
getAttachments: Accessor<Attachment[]>
|
||||
removeAttachment: (attachmentId: string) => void
|
||||
|
||||
submitOnEnter: Accessor<boolean>
|
||||
onSend: () => void
|
||||
|
||||
selectPreviousHistory: (force?: boolean) => boolean
|
||||
selectNextHistory: (force?: boolean) => boolean
|
||||
}
|
||||
|
||||
export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
||||
const insertNewlineAtCursor = () => {
|
||||
const textarea = options.getTextarea()
|
||||
const current = options.prompt()
|
||||
const start = textarea ? textarea.selectionStart : current.length
|
||||
const end = textarea ? textarea.selectionEnd : current.length
|
||||
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
|
||||
const nextCursor = start + 1
|
||||
|
||||
options.setPrompt(nextValue)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (!nextTextarea) return
|
||||
nextTextarea.focus()
|
||||
nextTextarea.setSelectionRange(nextCursor, nextCursor)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return function handleKeyDown(e: KeyboardEvent) {
|
||||
const textarea = options.getTextarea()
|
||||
if (!textarea) return
|
||||
|
||||
const currentText = options.prompt()
|
||||
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const isShellMode = options.mode() === "shell"
|
||||
|
||||
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
|
||||
e.preventDefault()
|
||||
options.setMode("shell")
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isPickerOpen() && e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
options.closePicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (isShellMode) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
options.setMode("normal")
|
||||
return
|
||||
}
|
||||
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
|
||||
e.preventDefault()
|
||||
options.setMode("normal")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const cursorPos = textarea.selectionStart
|
||||
const text = currentText
|
||||
|
||||
const pastePlaceholderRegex = createPastedPlaceholderRegex()
|
||||
let pasteMatch
|
||||
|
||||
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = pasteMatch.index
|
||||
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
|
||||
const pasteNumber = pasteMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
options.removeAttachment(attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlaceholderRegex = createImagePlaceholderRegex()
|
||||
let imageMatch
|
||||
|
||||
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = imageMatch.index
|
||||
const placeholderEnd = imageMatch.index + imageMatch[0].length
|
||||
const imageNumber = imageMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
options.removeAttachment(attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const mentionRegex = createMentionRegex()
|
||||
let mentionMatch
|
||||
|
||||
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
|
||||
const mentionStart = mentionMatch.index
|
||||
const mentionEnd = mentionMatch.index + mentionMatch[0].length
|
||||
const name = mentionMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= mentionStart &&
|
||||
textarea.selectionEnd >= mentionEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
e.preventDefault()
|
||||
|
||||
options.removeAttachment(attachment.id)
|
||||
|
||||
options.setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(mentionStart)
|
||||
return next
|
||||
})
|
||||
|
||||
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
const isModified = e.metaKey || e.ctrlKey
|
||||
|
||||
// If the picker is open, Enter should select from it.
|
||||
if (!isModified && options.isPickerOpen()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.submitOnEnter()) {
|
||||
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
insertNewlineAtCursor()
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+Enter always inserts a newline.
|
||||
if (e.shiftKey) {
|
||||
// If the picker is open, avoid selecting an item on Enter.
|
||||
if (options.isPickerOpen()) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
options.onSend()
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Cmd/Ctrl+Enter submits.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
if (options.isPickerOpen()) {
|
||||
options.closePicker()
|
||||
}
|
||||
options.onSend()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
const handled = options.selectPreviousHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const handled = options.selectNextHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
274
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
274
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||
import type { Agent } from "../../types/session"
|
||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
|
||||
type PickerItem =
|
||||
| { type: "agent"; agent: Agent }
|
||||
| { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } }
|
||||
| { type: "command"; command: SDKCommand }
|
||||
|
||||
type PromptPickerOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
resetHistoryNavigation?: () => void
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
|
||||
instanceAgents: Accessor<Agent[]>
|
||||
commands: Accessor<SDKCommand[]>
|
||||
}
|
||||
|
||||
type PromptPickerController = {
|
||||
showPicker: Accessor<boolean>
|
||||
pickerMode: Accessor<PickerMode>
|
||||
searchQuery: Accessor<string>
|
||||
atPosition: Accessor<number | null>
|
||||
ignoredAtPositions: Accessor<Set<number>>
|
||||
|
||||
setShowPicker: Setter<boolean>
|
||||
setPickerMode: Setter<PickerMode>
|
||||
setSearchQuery: Setter<string>
|
||||
setAtPosition: Setter<number | null>
|
||||
setIgnoredAtPositions: Setter<Set<number>>
|
||||
|
||||
handleInput: (e: Event) => void
|
||||
handlePickerSelect: (item: PickerItem) => void
|
||||
handlePickerClose: () => void
|
||||
}
|
||||
|
||||
export function usePromptPicker(options: PromptPickerOptions): PromptPickerController {
|
||||
const [showPicker, setShowPicker] = createSignal(false)
|
||||
const [pickerMode, setPickerMode] = createSignal<PickerMode>("mention")
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const value = target.value
|
||||
options.setPrompt(value)
|
||||
options.resetHistoryNavigation?.()
|
||||
|
||||
const cursorPos = target.selectionStart
|
||||
|
||||
// Slash command picker (only when editing the command token: "/<query>")
|
||||
if (value.startsWith("/") && cursorPos >= 1) {
|
||||
const firstWhitespaceIndex = value.slice(1).search(/\s/)
|
||||
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
|
||||
|
||||
if (cursorPos <= tokenEnd) {
|
||||
setPickerMode("command")
|
||||
setAtPosition(0)
|
||||
setSearchQuery(value.substring(1, cursorPos))
|
||||
setShowPicker(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const textBeforeCursor = value.substring(0, cursorPos)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||
|
||||
const previousAtPosition = atPosition()
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||
setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(previousAtPosition)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
|
||||
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
|
||||
|
||||
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||
setPickerMode("mention")
|
||||
setAtPosition(lastAtIndex)
|
||||
setSearchQuery(textAfterAt)
|
||||
setShowPicker(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
}
|
||||
|
||||
function handlePickerSelect(item: PickerItem) {
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (item.type === "command") {
|
||||
const name = item.command.name
|
||||
const currentPrompt = options.prompt()
|
||||
|
||||
const afterSlash = currentPrompt.slice(1)
|
||||
const firstWhitespaceIndex = afterSlash.search(/\s/)
|
||||
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
|
||||
|
||||
const before = ""
|
||||
const after = currentPrompt.substring(tokenEnd)
|
||||
const newPrompt = before + `/${name} ` + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = `/${name} `.length
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
nextTextarea.focus()
|
||||
}
|
||||
}, 0)
|
||||
} else if (item.type === "agent") {
|
||||
const agentName = item.agent.name
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "agent" && att.source.name === agentName,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createAgentAttachment(agentName)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${agentName}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const displayPath = item.file.path
|
||||
const relativePath = item.file.relativePath ?? displayPath
|
||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||
|
||||
if (isFolder) {
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const newPrompt = before + folderMention + after
|
||||
options.setPrompt(newPrompt)
|
||||
setSearchQuery(folderMention)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + 1 + folderMention.length
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||
const pathSegments = normalizedPath.split("/")
|
||||
const filename = (() => {
|
||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||
return candidate === "." ? "/" : candidate
|
||||
})()
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(
|
||||
normalizedPath,
|
||||
filename,
|
||||
"text/plain",
|
||||
undefined,
|
||||
options.instanceFolder(),
|
||||
)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${normalizedPath}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
textarea?.focus()
|
||||
}
|
||||
|
||||
function handlePickerClose() {
|
||||
const pos = atPosition()
|
||||
if (pickerMode() === "mention" && pos !== null) {
|
||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||
}
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setTimeout(() => options.getTextarea()?.focus(), 0)
|
||||
}
|
||||
|
||||
return {
|
||||
showPicker,
|
||||
pickerMode,
|
||||
searchQuery,
|
||||
atPosition,
|
||||
ignoredAtPositions,
|
||||
|
||||
setShowPicker,
|
||||
setPickerMode,
|
||||
setSearchQuery,
|
||||
setAtPosition,
|
||||
setIgnoredAtPositions,
|
||||
|
||||
handleInput,
|
||||
handlePickerSelect,
|
||||
handlePickerClose,
|
||||
}
|
||||
}
|
||||
193
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
193
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { addToHistory, getHistory } from "../../stores/message-history"
|
||||
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type GetTextarea = () => HTMLTextAreaElement | undefined | null
|
||||
|
||||
type PromptStateOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
onSessionDraftLoaded?: (draft: string) => void
|
||||
}
|
||||
|
||||
type HistorySelectOptions = {
|
||||
force?: boolean
|
||||
isPickerOpen: boolean
|
||||
getTextarea: GetTextarea
|
||||
}
|
||||
|
||||
type PromptState = {
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
clearPrompt: () => void
|
||||
|
||||
draftLoadedNonce: Accessor<number>
|
||||
|
||||
history: Accessor<string[]>
|
||||
historyIndex: Accessor<number>
|
||||
historyDraft: Accessor<string | null>
|
||||
|
||||
resetHistoryNavigation: () => void
|
||||
clearHistoryDraft: () => void
|
||||
recordHistoryEntry: (entry: string) => Promise<void>
|
||||
|
||||
selectPreviousHistory: (options: HistorySelectOptions) => boolean
|
||||
selectNextHistory: (options: HistorySelectOptions) => boolean
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 100
|
||||
|
||||
export function usePromptState(options: PromptStateOptions): PromptState {
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
|
||||
|
||||
const setPrompt = (value: string) => {
|
||||
setPromptInternal(value)
|
||||
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
|
||||
}
|
||||
|
||||
const clearPrompt = () => {
|
||||
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
|
||||
setPromptInternal("")
|
||||
}
|
||||
|
||||
const resetHistoryNavigation = () => {
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
|
||||
const clearHistoryDraft = () => {
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => `${options.instanceId()}:${options.sessionId()}`,
|
||||
() => {
|
||||
const instanceId = options.instanceId()
|
||||
const sessionId = options.sessionId()
|
||||
|
||||
onCleanup(() => {
|
||||
// Persist the previous session's draft when switching sessions.
|
||||
setSessionDraftPrompt(instanceId, sessionId, prompt())
|
||||
})
|
||||
|
||||
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
setPromptInternal(storedPrompt)
|
||||
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
|
||||
|
||||
resetHistoryNavigation()
|
||||
|
||||
setDraftLoadedNonce((prev) => prev + 1)
|
||||
options.onSessionDraftLoaded?.(storedPrompt)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const loaded = await getHistory(options.instanceFolder())
|
||||
setHistory(loaded)
|
||||
})()
|
||||
})
|
||||
|
||||
const recordHistoryEntry = async (entry: string) => {
|
||||
try {
|
||||
await addToHistory(options.instanceFolder(), entry)
|
||||
setHistory((prev) => {
|
||||
const next = [entry, ...prev]
|
||||
if (next.length > HISTORY_LIMIT) {
|
||||
next.length = HISTORY_LIMIT
|
||||
}
|
||||
return next
|
||||
})
|
||||
setHistoryIndex(-1)
|
||||
} catch (historyError) {
|
||||
log.error("Failed to update prompt history:", historyError)
|
||||
}
|
||||
}
|
||||
|
||||
const canUseHistory = (selectOptions: HistorySelectOptions) => {
|
||||
if (selectOptions.force) return true
|
||||
if (selectOptions.isPickerOpen) return false
|
||||
|
||||
const textarea = selectOptions.getTextarea()
|
||||
if (!textarea) return false
|
||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
}
|
||||
|
||||
const focusTextareaEnd = (getTextarea: GetTextarea) => {
|
||||
const textarea = getTextarea()
|
||||
if (!textarea) return
|
||||
setTimeout(() => {
|
||||
const next = getTextarea()
|
||||
if (!next) return
|
||||
const pos = next.value.length
|
||||
next.setSelectionRange(pos, pos)
|
||||
next.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(selectOptions)) return false
|
||||
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
focusTextareaEnd(selectOptions.getTextarea)
|
||||
return true
|
||||
}
|
||||
|
||||
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(selectOptions)) return false
|
||||
if (historyIndex() === -1) return false
|
||||
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
const draft = historyDraft()
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
focusTextareaEnd(selectOptions.getTextarea)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
setPrompt,
|
||||
clearPrompt,
|
||||
|
||||
draftLoadedNonce,
|
||||
|
||||
history,
|
||||
historyIndex,
|
||||
historyDraft,
|
||||
|
||||
resetHistoryNavigation,
|
||||
clearHistoryDraft,
|
||||
recordHistoryEntry,
|
||||
|
||||
selectPreviousHistory,
|
||||
selectNextHistory,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import { Show, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
@@ -15,6 +14,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -53,52 +53,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||
|
||||
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(/pasted #(\d+)/)
|
||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||
|
||||
const currentText = textarea?.value ?? ""
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = nextText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
let promptInputApi: PromptInputApi | null = null
|
||||
let pendingPromptText: string | null = null
|
||||
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
@@ -135,6 +92,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
// Defer until the session pane is visible and the textarea is mounted.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
||||
if (!textarea) return
|
||||
if (textarea.disabled) return
|
||||
@@ -149,8 +111,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
},
|
||||
),
|
||||
)
|
||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
@@ -158,18 +119,31 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
||||
quoteHandler = handler
|
||||
function registerPromptInputApi(api: PromptInputApi) {
|
||||
promptInputApi = api
|
||||
|
||||
if (pendingPromptText) {
|
||||
api.setPromptText(pendingPromptText, { focus: true })
|
||||
pendingPromptText = null
|
||||
}
|
||||
|
||||
if (pendingSelectionInsert) {
|
||||
api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode)
|
||||
pendingSelectionInsert = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (quoteHandler === handler) {
|
||||
quoteHandler = null
|
||||
if (promptInputApi === api) {
|
||||
promptInputApi = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
||||
if (quoteHandler) {
|
||||
quoteHandler(text, mode)
|
||||
function handleQuoteSelection(text: string, mode: PromptInsertMode) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.insertSelection(text, mode)
|
||||
} else {
|
||||
pendingSelectionInsert = { text, mode }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,14 +204,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
)
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
if (restoredText) {
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
if (restoredText) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||
} else {
|
||||
pendingPromptText = restoredText
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
|
||||
@@ -271,14 +244,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
if (restoredText) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||
} else {
|
||||
pendingPromptText = restoredText
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
|
||||
@@ -327,39 +299,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
@@ -371,11 +317,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerQuoteHandler={registerQuoteHandler}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user