diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx index faf70811..2f4c87e2 100644 --- a/packages/desktop/src/components/editor-pane.tsx +++ b/packages/desktop/src/components/editor-pane.tsx @@ -13,30 +13,16 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { LocalFile } from "@/context/local" import { Code } from "@/components/code" -import PromptForm from "@/components/prompt-form" -import { useLocal, useSDK, useSync } from "@/context" -import { getFilename } from "@/utils" +import { useLocal } from "@/context" import type { JSX } from "solid-js" interface EditorPaneProps { - layoutKey: string - timelinePane: string onFileClick: (file: LocalFile) => void - onOpenModelSelect: () => void - onInputRefChange: (element: HTMLTextAreaElement | null) => void } export default function EditorPane(props: EditorPaneProps): JSX.Element { - const [localProps] = splitProps(props, [ - "layoutKey", - "timelinePane", - "onFileClick", - "onOpenModelSelect", - "onInputRefChange", - ]) + const [localProps] = splitProps(props, ["onFileClick"]) const local = useLocal() - const sdk = useSDK() - const sync = useSync() const [activeItem, setActiveItem] = createSignal(undefined) const navigateChange = (dir: 1 | -1) => { @@ -55,73 +41,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { local.file.close(file.path) } - const handlePromptSubmit = async (prompt: string) => { - const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane) - ? local.session.active() - : undefined - let session = existingSession - if (!session) { - const created = await sdk.session.create() - session = created.data ?? undefined - } - if (!session) return - local.session.setActive(session.id) - local.layout.show(localProps.layoutKey, localProps.timelinePane) - - await sdk.session.prompt({ - path: { id: session.id }, - body: { - agent: local.agent.current()!.name, - model: { - modelID: local.model.current()!.id, - providerID: local.model.current()!.provider.id, - }, - parts: [ - { - type: "text", - text: prompt, - }, - ...(local.context.active() - ? [ - { - type: "file" as const, - mime: "text/plain", - url: `file://${local.context.active()!.absolute}`, - filename: local.context.active()!.name, - source: { - type: "file" as const, - text: { - value: "@" + local.context.active()!.name, - start: 0, - end: 0, - }, - path: local.context.active()!.absolute, - }, - }, - ] - : []), - ...local.context.all().flatMap((file) => [ - { - type: "file" as const, - mime: "text/plain", - url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`, - filename: getFilename(file.path), - source: { - type: "file" as const, - text: { - value: "@" + getFilename(file.path), - start: 0, - end: 0, - }, - path: sync.absolute(file.path), - }, - }, - ]), - ], - }, - }) - } - const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -146,7 +65,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { return (
- - - local.layout.toggle(localProps.layoutKey, localProps.timelinePane)} - > - - -
@@ -283,16 +184,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { })()} - localProps.onInputRefChange(element ?? null)} - /> ) } diff --git a/packages/desktop/src/components/prompt-form-helpers.ts b/packages/desktop/src/components/prompt-form-helpers.ts new file mode 100644 index 00000000..cc657c80 --- /dev/null +++ b/packages/desktop/src/components/prompt-form-helpers.ts @@ -0,0 +1,164 @@ +import type { TextSelection } from "@/context/local" +import { getFilename } from "@/utils" + +export interface PromptTextPart { + kind: "text" + value: string +} + +export interface PromptAttachmentPart { + kind: "attachment" + token: string + display: string + path: string + selection?: TextSelection + origin: "context" | "active" +} + +export interface PromptInterimPart { + kind: "interim" + value: string + leadingSpace: boolean +} + +export type PromptContentPart = PromptTextPart | PromptAttachmentPart + +export type PromptDisplaySegment = + | { kind: "text"; value: string } + | { kind: "attachment"; part: PromptAttachmentPart; source: string } + | PromptInterimPart + +export interface AttachmentCandidate { + origin: "context" | "active" + path: string + selection?: TextSelection + display: string +} + +export interface PromptSubmitValue { + text: string + parts: PromptContentPart[] +} + +export const mentionPattern = /@([A-Za-z0-9_\-./]+)/g +export const mentionTriggerPattern = /(^|\s)@([A-Za-z0-9_\-./]*)$/ + +export type PromptSegment = (PromptTextPart | PromptAttachmentPart) & { + start: number + end: number +} + +export type PromptAttachmentSegment = PromptAttachmentPart & { + start: number + end: number +} + +function pushTextPart(parts: PromptContentPart[], value: string) { + if (!value) return + const last = parts[parts.length - 1] + if (last && last.kind === "text") { + last.value += value + return + } + parts.push({ kind: "text", value }) +} + +function addTextSegment(segments: PromptSegment[], start: number, value: string) { + if (!value) return + segments.push({ kind: "text", value, start, end: start + value.length }) +} + +export function createAttachmentDisplay(path: string, selection?: TextSelection) { + const base = getFilename(path) + if (!selection) return base + return `${base} (${selection.startLine}-${selection.endLine})` +} + +export function registerCandidate( + map: Map, + candidate: AttachmentCandidate, + tokens: (string | undefined)[], +) { + for (const token of tokens) { + if (!token) continue + const normalized = token.toLowerCase() + if (map.has(normalized)) continue + map.set(normalized, candidate) + } +} + +export function parsePrompt(value: string, lookup: Map) { + const segments: PromptSegment[] = [] + if (!value) return { parts: [] as PromptContentPart[], segments } + + const pushTextRange = (rangeStart: number, rangeEnd: number) => { + if (rangeEnd <= rangeStart) return + const text = value.slice(rangeStart, rangeEnd) + let cursor = 0 + for (const match of text.matchAll(mentionPattern)) { + const localIndex = match.index ?? 0 + if (localIndex > cursor) { + addTextSegment(segments, rangeStart + cursor, text.slice(cursor, localIndex)) + } + const token = match[1] + const candidate = lookup.get(token.toLowerCase()) + if (candidate) { + const start = rangeStart + localIndex + const end = start + match[0].length + segments.push({ + kind: "attachment", + token, + display: candidate.display, + path: candidate.path, + selection: candidate.selection, + origin: candidate.origin, + start, + end, + }) + } else { + addTextSegment(segments, rangeStart + localIndex, match[0]) + } + cursor = localIndex + match[0].length + } + if (cursor < text.length) { + addTextSegment(segments, rangeStart + cursor, text.slice(cursor)) + } + } + + pushTextRange(0, value.length) + + const parts: PromptContentPart[] = [] + for (const segment of segments) { + if (segment.kind === "text") { + pushTextPart(parts, segment.value) + } else { + const { start, end, ...attachment } = segment + parts.push(attachment as PromptAttachmentPart) + } + } + return { parts, segments } +} + +export function composeDisplaySegments( + segments: PromptSegment[], + inputValue: string, + interim: string, +): PromptDisplaySegment[] { + if (segments.length === 0 && !interim) return [] + + const display: PromptDisplaySegment[] = segments.map((segment) => { + if (segment.kind === "text") { + return { kind: "text", value: segment.value } + } + const { start, end, ...part } = segment + const placeholder = inputValue.slice(start, end) + return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder } + }) + + if (interim) { + const leadingSpace = !!(inputValue && !inputValue.endsWith(" ") && !interim.startsWith(" ")) + display.push({ kind: "interim", value: interim, leadingSpace }) + } + + return display +} diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts new file mode 100644 index 00000000..026b8435 --- /dev/null +++ b/packages/desktop/src/components/prompt-form-hooks.ts @@ -0,0 +1,396 @@ +import { createEffect, createMemo, createResource, type Accessor } from "solid-js" +import type { SetStoreFunction } from "solid-js/store" +import { getDirectory, getFilename } from "@/utils" +import { createSpeechRecognition } from "@/utils/speech" +import { + createAttachmentDisplay, + mentionPattern, + mentionTriggerPattern, + type PromptAttachmentPart, + type PromptAttachmentSegment, +} from "./prompt-form-helpers" +import type { LocalFile, TextSelection } from "@/context/local" + +export type MentionRange = { + start: number + end: number +} + +export interface PromptFormState { + promptInput: string + isDragOver: boolean + mentionOpen: boolean + mentionQuery: string + mentionRange: MentionRange | undefined + mentionIndex: number + mentionAnchorOffset: { x: number; y: number } + inlineAliases: Map +} + +interface MentionControllerOptions { + state: PromptFormState + setState: SetStoreFunction + attachmentSegments: Accessor + getInputRef: () => HTMLTextAreaElement | undefined + getOverlayRef: () => HTMLDivElement | undefined + getMeasureRef: () => HTMLDivElement | undefined + searchFiles: (query: string) => Promise + resolveFile: (path: string) => LocalFile | undefined + addContextFile: (path: string, selection?: TextSelection) => void + getActiveContext: () => { path: string; selection?: TextSelection } | undefined +} + +interface MentionKeyDownOptions { + event: KeyboardEvent & { currentTarget: HTMLTextAreaElement } + mentionItems: () => string[] + insertMention: (path: string) => void +} + +interface ScrollSyncOptions { + state: PromptFormState + getInputRef: () => HTMLTextAreaElement | undefined + getOverlayRef: () => HTMLDivElement | undefined + interim: Accessor + updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void +} + +export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) { + return createSpeechRecognition({ + onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text), + }) +} + +export function useMentionController(options: MentionControllerOptions) { + const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined)) + const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => { + if (!options.state.mentionOpen) return [] + return options.searchFiles(query ?? "") + }) + const mentionItems = createMemo(() => mentionResults() ?? []) + + createEffect(() => { + if (!options.state.mentionOpen) return + options.state.mentionQuery + options.setState("mentionIndex", 0) + }) + + createEffect(() => { + if (!options.state.mentionOpen) return + queueMicrotask(() => { + const input = options.getInputRef() + if (!input) return + if (document.activeElement === input) return + input.focus() + }) + }) + + createEffect(() => { + const used = new Set() + for (const match of options.state.promptInput.matchAll(mentionPattern)) { + const token = match[1] + if (token) used.add(token.toLowerCase()) + } + options.setState("inlineAliases", (prev) => { + if (prev.size === 0) return prev + const next = new Map(prev) + let changed = false + for (const key of prev.keys()) { + if (!used.has(key.toLowerCase())) { + next.delete(key) + changed = true + } + } + return changed ? next : prev + }) + }) + + createEffect(() => { + if (!options.state.mentionOpen) return + const items = mentionItems() + if (items.length === 0) { + options.setState("mentionIndex", 0) + return + } + if (options.state.mentionIndex < items.length) return + options.setState("mentionIndex", items.length - 1) + }) + + createEffect(() => { + if (!options.state.mentionOpen) return + const rangeValue = options.state.mentionRange + if (!rangeValue) return + options.state.promptInput + queueMicrotask(() => { + const input = options.getInputRef() + if (!input) return + updateMentionPosition(input, rangeValue) + }) + }) + + function closeMention() { + if (options.state.mentionOpen) options.setState("mentionOpen", false) + options.setState("mentionQuery", "") + options.setState("mentionRange", undefined) + options.setState("mentionIndex", 0) + mutateMentionResults(() => undefined) + options.setState("mentionAnchorOffset", { x: 0, y: 0 }) + } + + function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) { + const measure = options.getMeasureRef() + if (!measure) return + if (!rangeValue) return + measure.style.width = `${element.clientWidth}px` + const measurement = element.value.slice(0, rangeValue.end) + measure.textContent = measurement + const caretSpan = document.createElement("span") + caretSpan.textContent = "\u200b" + measure.append(caretSpan) + const caretRect = caretSpan.getBoundingClientRect() + const containerRect = measure.getBoundingClientRect() + measure.removeChild(caretSpan) + const left = caretRect.left - containerRect.left + const top = caretRect.top - containerRect.top - element.scrollTop + options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top }) + } + + function isValidMentionQuery(value: string) { + return /^[A-Za-z0-9_\-./]*$/.test(value) + } + + function syncMentionFromCaret(element: HTMLTextAreaElement) { + if (!options.state.mentionOpen) return + const rangeValue = options.state.mentionRange + if (!rangeValue) { + closeMention() + return + } + const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length + if (rangeValue.start < 0 || rangeValue.start >= element.value.length) { + closeMention() + return + } + if (element.value[rangeValue.start] !== "@") { + closeMention() + return + } + if (caret <= rangeValue.start) { + closeMention() + return + } + const mentionValue = element.value.slice(rangeValue.start + 1, caret) + if (!isValidMentionQuery(mentionValue)) { + closeMention() + return + } + options.setState("mentionRange", { start: rangeValue.start, end: caret }) + options.setState("mentionQuery", mentionValue) + updateMentionPosition(element, { start: rangeValue.start, end: caret }) + } + + function tryOpenMentionFromCaret(element: HTMLTextAreaElement) { + const selectionStart = element.selectionStart ?? element.value.length + const selectionEnd = element.selectionEnd ?? selectionStart + if (selectionStart !== selectionEnd) return false + const caret = selectionEnd + if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) { + return false + } + const before = element.value.slice(0, caret) + const match = before.match(mentionTriggerPattern) + if (!match) return false + const token = match[2] ?? "" + const start = caret - token.length - 1 + if (start < 0) return false + options.setState("mentionOpen", true) + options.setState("mentionRange", { start, end: caret }) + options.setState("mentionQuery", token) + options.setState("mentionIndex", 0) + queueMicrotask(() => { + updateMentionPosition(element, { start, end: caret }) + }) + return true + } + + function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) { + const element = event.currentTarget + options.setState("promptInput", element.value) + if (options.state.mentionOpen) { + syncMentionFromCaret(element) + if (options.state.mentionOpen) return + } + const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false + if (!isDeletion && tryOpenMentionFromCaret(element)) return + closeMention() + } + + function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) { + if (!options.state.mentionOpen) return false + const list = items() + if (event.key === "ArrowDown") { + event.preventDefault() + if (list.length === 0) return true + const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1 + options.setState("mentionIndex", next) + return true + } + if (event.key === "ArrowUp") { + event.preventDefault() + if (list.length === 0) return true + const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1 + options.setState("mentionIndex", previous) + return true + } + if (event.key === "Enter") { + event.preventDefault() + const targetItem = list[options.state.mentionIndex] ?? list[0] + if (targetItem) insertMention(targetItem) + return true + } + if (event.key === "Escape") { + event.preventDefault() + closeMention() + return true + } + return false + } + + function generateMentionAlias(path: string) { + const existing = new Set() + for (const key of options.state.inlineAliases.keys()) { + existing.add(key.toLowerCase()) + } + for (const match of options.state.promptInput.matchAll(mentionPattern)) { + const token = match[1] + if (token) existing.add(token.toLowerCase()) + } + + const base = getFilename(path) + if (base) { + if (!existing.has(base.toLowerCase())) return base + } + + const directory = getDirectory(path) + if (base && directory) { + const segments = directory.split("/").filter(Boolean) + for (let i = segments.length - 1; i >= 0; i -= 1) { + const candidate = `${segments.slice(i).join("/")}/${base}` + if (!existing.has(candidate.toLowerCase())) return candidate + } + } + + if (!existing.has(path.toLowerCase())) return path + + const fallback = base || path || "file" + let index = 2 + let candidate = `${fallback}-${index}` + while (existing.has(candidate.toLowerCase())) { + index += 1 + candidate = `${fallback}-${index}` + } + return candidate + } + + function insertMention(path: string) { + const input = options.getInputRef() + if (!input) return + const rangeValue = options.state.mentionRange + if (!rangeValue) return + const node = options.resolveFile(path) + const alias = generateMentionAlias(path) + const mentionText = `@${alias}` + const value = options.state.promptInput + const before = value.slice(0, rangeValue.start) + const after = value.slice(rangeValue.end) + const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) + const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) + const leading = needsLeadingSpace ? `${before} ` : before + const trailingSpacer = needsTrailingSpace ? " " : "" + const nextValue = `${leading}${mentionText}${trailingSpacer}${after}` + const origin = options.getActiveContext()?.path === path ? "active" : "context" + const part: PromptAttachmentPart = { + kind: "attachment", + token: alias, + display: createAttachmentDisplay(path, node?.selection), + path, + selection: node?.selection, + origin, + } + options.setState("promptInput", nextValue) + if (input.value !== nextValue) { + input.value = nextValue + } + options.setState("inlineAliases", (prev) => { + const next = new Map(prev) + next.set(alias, part) + return next + }) + options.addContextFile(path, node?.selection) + closeMention() + queueMicrotask(() => { + const caret = leading.length + mentionText.length + trailingSpacer.length + input.setSelectionRange(caret, caret) + syncMentionFromCaret(input) + }) + } + + return { + mentionResults, + mentionItems, + closeMention, + syncMentionFromCaret, + tryOpenMentionFromCaret, + updateMentionPosition, + handlePromptInput, + handleMentionKeyDown, + insertMention, + } +} + +export function usePromptScrollSync(options: ScrollSyncOptions) { + let shouldAutoScroll = true + + createEffect(() => { + options.state.promptInput + options.interim() + queueMicrotask(() => { + const input = options.getInputRef() + const overlay = options.getOverlayRef() + if (!input || !overlay) return + if (!shouldAutoScroll) { + overlay.scrollTop = input.scrollTop + if (options.state.mentionOpen) options.updateMentionPosition(input) + return + } + const maxInputScroll = input.scrollHeight - input.clientHeight + const next = maxInputScroll > 0 ? maxInputScroll : 0 + input.scrollTop = next + overlay.scrollTop = next + if (options.state.mentionOpen) options.updateMentionPosition(input) + }) + }) + + function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) { + const target = event.currentTarget + shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 + const overlay = options.getOverlayRef() + if (overlay) overlay.scrollTop = target.scrollTop + if (options.state.mentionOpen) options.updateMentionPosition(target) + } + + function resetScrollPosition() { + shouldAutoScroll = true + const input = options.getInputRef() + const overlay = options.getOverlayRef() + if (input) input.scrollTop = 0 + if (overlay) overlay.scrollTop = 0 + } + + return { + handlePromptScroll, + resetScrollPosition, + setAutoScroll: (value: boolean) => { + shouldAutoScroll = value + }, + } +} diff --git a/packages/desktop/src/components/prompt-form.tsx b/packages/desktop/src/components/prompt-form.tsx index 9d7c45a3..5e644159 100644 --- a/packages/desktop/src/components/prompt-form.tsx +++ b/packages/desktop/src/components/prompt-form.tsx @@ -1,15 +1,25 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { For, Show, createMemo, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Popover } from "@kobalte/core/popover" import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui" import { Select } from "@/components/select" import { useLocal } from "@/context" import type { FileContext, LocalFile } from "@/context/local" -import { getFilename } from "@/utils" -import { createSpeechRecognition } from "@/utils/speech" +import { getDirectory, getFilename } from "@/utils" +import { composeDisplaySegments, createAttachmentDisplay, parsePrompt, registerCandidate } from "./prompt-form-helpers" +import type { + AttachmentCandidate, + PromptAttachmentPart, + PromptAttachmentSegment, + PromptDisplaySegment, + PromptSubmitValue, +} from "./prompt-form-helpers" +import { useMentionController, usePromptScrollSync, usePromptSpeech, type PromptFormState } from "./prompt-form-hooks" interface PromptFormProps { class?: string classList?: Record - onSubmit: (prompt: string) => Promise | void + onSubmit: (prompt: PromptSubmitValue) => Promise | void onOpenModelSelect: () => void onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void } @@ -17,8 +27,16 @@ interface PromptFormProps { export default function PromptForm(props: PromptFormProps) { const local = useLocal() - const [prompt, setPrompt] = createSignal("") - const [isDragOver, setIsDragOver] = createSignal(false) + const [state, setState] = createStore({ + promptInput: "", + isDragOver: false, + mentionOpen: false, + mentionQuery: "", + mentionRange: undefined, + mentionIndex: 0, + mentionAnchorOffset: { x: 0, y: 0 }, + inlineAliases: new Map(), + }) const placeholderText = "Start typing or speaking..." @@ -28,79 +46,212 @@ export default function PromptForm(props: PromptFormProps) { interim: interimTranscript, start: startSpeech, stop: stopSpeech, - } = createSpeechRecognition({ - onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text), - }) + } = usePromptSpeech((updater) => setState("promptInput", updater)) let inputRef: HTMLTextAreaElement | undefined = undefined let overlayContainerRef: HTMLDivElement | undefined = undefined - let shouldAutoScroll = true + let mentionMeasureRef: HTMLDivElement | undefined = undefined - const promptContent = createMemo(() => { - const base = prompt() || "" + const attachmentLookup = createMemo(() => { + const map = new Map() + const activeFile = local.context.active() + if (activeFile) { + registerCandidate( + map, + { + origin: "active", + path: activeFile.path, + selection: activeFile.selection, + display: createAttachmentDisplay(activeFile.path, activeFile.selection), + }, + [activeFile.path, getFilename(activeFile.path)], + ) + } + for (const item of local.context.all()) { + registerCandidate( + map, + { + origin: "context", + path: item.path, + selection: item.selection, + display: createAttachmentDisplay(item.path, item.selection), + }, + [item.path, getFilename(item.path)], + ) + } + for (const [alias, part] of state.inlineAliases) { + registerCandidate( + map, + { + origin: part.origin, + path: part.path, + selection: part.selection, + display: part.display ?? createAttachmentDisplay(part.path, part.selection), + }, + [alias], + ) + } + return map + }) + + const parsedPrompt = createMemo(() => parsePrompt(state.promptInput, attachmentLookup())) + const baseParts = createMemo(() => parsedPrompt().parts) + const attachmentSegments = createMemo(() => + parsedPrompt().segments.filter((segment): segment is PromptAttachmentSegment => segment.kind === "attachment"), + ) + + const { + mentionResults, + mentionItems, + closeMention, + syncMentionFromCaret, + updateMentionPosition, + handlePromptInput, + handleMentionKeyDown, + insertMention, + } = useMentionController({ + state, + setState, + attachmentSegments, + getInputRef: () => inputRef, + getOverlayRef: () => overlayContainerRef, + getMeasureRef: () => mentionMeasureRef, + searchFiles: (query) => local.file.search(query), + resolveFile: (path) => local.file.node(path) ?? undefined, + addContextFile: (path, selection) => + local.context.add({ + type: "file", + path, + selection, + }), + getActiveContext: () => local.context.active() ?? undefined, + }) + + const { handlePromptScroll, resetScrollPosition } = usePromptScrollSync({ + state, + getInputRef: () => inputRef, + getOverlayRef: () => overlayContainerRef, + interim: () => (isRecording() ? interimTranscript() : ""), + updateMentionPosition, + }) + + const displaySegments = createMemo(() => { + const value = state.promptInput + const segments = parsedPrompt().segments const interim = isRecording() ? interimTranscript() : "" - if (!base && !interim) { - return {placeholderText} - } - const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ") - return ( - <> - {base} - {interim && ( - - {needsSpace ? " " : ""} - {interim} - - )} - - ) + return composeDisplaySegments(segments, value, interim) }) - createEffect(() => { - prompt() - interimTranscript() - queueMicrotask(() => { - if (!inputRef) return - if (!overlayContainerRef) return - if (!shouldAutoScroll) { - overlayContainerRef.scrollTop = inputRef.scrollTop - return + const hasDisplaySegments = createMemo(() => displaySegments().length > 0) + + function handleAttachmentNavigation( + event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }, + direction: "left" | "right", + ) { + const element = event.currentTarget + const caret = element.selectionStart ?? 0 + const segments = attachmentSegments() + if (direction === "left") { + let match = segments.find((segment) => caret > segment.start && caret <= segment.end) + if (!match && element.selectionStart !== element.selectionEnd) { + match = segments.find( + (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end, + ) } - scrollPromptToEnd() - }) - }) - - const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { - if (event.isComposing) return - if (event.key === "Enter" && !event.shiftKey) { + if (!match) return false event.preventDefault() - inputRef?.form?.requestSubmit() + if (element.selectionStart === match.start && element.selectionEnd === match.end) { + const next = Math.max(0, match.start) + element.setSelectionRange(next, next) + syncMentionFromCaret(element) + return true + } + element.setSelectionRange(match.start, match.end) + syncMentionFromCaret(element) + return true } + if (direction === "right") { + let match = segments.find((segment) => caret >= segment.start && caret < segment.end) + if (!match && element.selectionStart !== element.selectionEnd) { + match = segments.find( + (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end, + ) + } + if (!match) return false + event.preventDefault() + if (element.selectionStart === match.start && element.selectionEnd === match.end) { + const next = match.end + element.setSelectionRange(next, next) + syncMentionFromCaret(element) + return true + } + element.setSelectionRange(match.start, match.end) + syncMentionFromCaret(element) + return true + } + return false } - const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => { + function renderAttachmentChip(part: PromptAttachmentPart, _placeholder: string) { + const display = part.display ?? createAttachmentDisplay(part.path, part.selection) + return @{display} + } + + function renderTextSegment(value: string) { + if (!value) return undefined + return {value} + } + + function handlePromptKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) { + if (event.isComposing) return const target = event.currentTarget - shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 - if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop - } + const key = event.key - const scrollPromptToEnd = () => { - if (!inputRef) return - const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight - const next = maxInputScroll > 0 ? maxInputScroll : 0 - inputRef.scrollTop = next - if (overlayContainerRef) overlayContainerRef.scrollTop = next - shouldAutoScroll = true + const handled = handleMentionKeyDown({ + event, + mentionItems, + insertMention, + }) + if (handled) return + + if (!state.mentionOpen) { + if (key === "ArrowLeft") { + if (handleAttachmentNavigation(event, "left")) return + } + if (key === "ArrowRight") { + if (handleAttachmentNavigation(event, "right")) return + } + } + + if (key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End") { + queueMicrotask(() => { + syncMentionFromCaret(target) + }) + } + + if (key === "Enter" && !event.shiftKey) { + event.preventDefault() + target.form?.requestSubmit() + } } const handleSubmit = async (event: SubmitEvent) => { event.preventDefault() - const currentPrompt = prompt() - setPrompt("") - shouldAutoScroll = true - if (overlayContainerRef) overlayContainerRef.scrollTop = 0 + const parts = baseParts() + const text = parts + .map((part) => { + if (part.kind === "text") return part.value + return `@${part.path}` + }) + .join("") + + const currentPrompt: PromptSubmitValue = { + text, + parts, + } + setState("promptInput", "") + resetScrollPosition() if (inputRef) { - inputRef.scrollTop = 0 inputRef.blur() } @@ -114,26 +265,25 @@ export default function PromptForm(props: PromptFormProps) { return (
{ const evt = event as unknown as globalThis.DragEvent if (evt.dataTransfer?.types.includes("text/plain")) { evt.preventDefault() - setIsDragOver(true) + setState("isDragOver", true) } }} onDragLeave={(event) => { if (event.currentTarget === event.target) { - setIsDragOver(false) + setState("isDragOver", false) } }} onDragOver={(event) => { @@ -146,7 +296,7 @@ export default function PromptForm(props: PromptFormProps) { onDrop={(event) => { const evt = event as unknown as globalThis.DragEvent evt.preventDefault() - setIsDragOver(false) + setState("isDragOver", false) const data = evt.dataTransfer?.getData("text/plain") if (data && data.startsWith("file:")) { @@ -177,9 +327,24 @@ export default function PromptForm(props: PromptFormProps) { inputRef = element ?? undefined props.onInputRefChange?.(inputRef) }} - value={prompt()} - onInput={(event) => setPrompt(event.currentTarget.value)} + value={state.promptInput} + onInput={handlePromptInput} onKeyDown={handlePromptKeyDown} + onClick={(event) => + queueMicrotask(() => { + syncMentionFromCaret(event.currentTarget) + }) + } + onSelect={(event) => + queueMicrotask(() => { + syncMentionFromCaret(event.currentTarget) + }) + } + onBlur={(event) => { + const next = event.relatedTarget as HTMLElement | null + if (next && next.closest('[data-mention-popover="true"]')) return + closeMention() + }} onScroll={handlePromptScroll} placeholder={placeholderText} autocapitalize="off" @@ -196,10 +361,30 @@ export default function PromptForm(props: PromptFormProps) { }} class="pointer-events-none absolute inset-0 overflow-hidden" > -
- {promptContent()} -
+
+
{ + mentionMeasureRef = element ?? undefined + }} + class="pointer-events-none invisible absolute inset-0 whitespace-pre-wrap text-base font-light leading-relaxed px-0.5" + aria-hidden="true" + >
+ setState("mentionIndex", index)} + onSelect={insertMention} + />
@@ -293,3 +478,104 @@ const FileTag = (props: { file: FileContext; onClose: () => void }) => (
) + +function PromptDisplayOverlay(props: { + hasDisplaySegments: boolean + displaySegments: PromptDisplaySegment[] + placeholder: string + renderAttachmentChip: (part: PromptAttachmentPart, placeholder: string) => JSX.Element + renderTextSegment: (value: string) => JSX.Element | undefined +}) { + return ( +
+ {props.placeholder}}> + + {(segment) => { + if (segment.kind === "text") { + return props.renderTextSegment(segment.value) + } + if (segment.kind === "attachment") { + return props.renderAttachmentChip(segment.part, segment.source) + } + return ( + + {segment.leadingSpace ? ` ${segment.value}` : segment.value} + + ) + }} + + +
+ ) +} + +function MentionSuggestions(props: { + open: boolean + anchor: { x: number; y: number } + loading: boolean + items: string[] + activeIndex: number + onHover: (index: number) => void + onSelect: (path: string) => void +}) { + return ( + + + ) +} + +export type { + PromptAttachmentPart, + PromptAttachmentSegment, + PromptContentPart, + PromptDisplaySegment, + PromptSubmitValue, +} from "./prompt-form-helpers" diff --git a/packages/desktop/src/components/select.tsx b/packages/desktop/src/components/select.tsx index 3df8c999..6ab3f401 100644 --- a/packages/desktop/src/components/select.tsx +++ b/packages/desktop/src/components/select.tsx @@ -74,7 +74,7 @@ export function Select(props: SelectProps & ButtonProps) { [props.class ?? ""]: !!props.class, }} > - > + class="truncate"> {(state) => { const selected = state.selectedOption() ?? props.current if (!selected) return props.placeholder || "" @@ -84,10 +84,11 @@ export function Select(props: SelectProps & ButtonProps) { - + + @@ -99,7 +100,7 @@ export function Select(props: SelectProps & ButtonProps) { "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, }} > - + diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 4c2a3d3b..b04c70f0 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -202,6 +202,13 @@ function init() { } } + const init = async (path: string) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + if (store.node[relativePath].loaded) return + return load(relativePath) + } + const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { const relativePath = relative(path) if (!store.node[relativePath]) await fetch(path) @@ -271,6 +278,7 @@ function init() { update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), open, load, + init, close(path: string) { setStore("opened", (opened) => opened.filter((x) => x !== path)) if (store.active === path) { @@ -473,11 +481,16 @@ function init() { const context = (() => { const [store, setStore] = createStore<{ activeTab: boolean + files: string[] + activeFile?: string items: (ContextItem & { key: string })[] }>({ activeTab: true, + files: [], items: [], }) + const files = createMemo(() => store.files.map((x) => file.node(x))) + const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) return { all() { @@ -505,6 +518,17 @@ function init() { remove(key: string) { setStore("items", (x) => x.filter((x) => x.key !== key)) }, + files, + openFile(path: string) { + file.init(path).then(() => { + setStore("files", (x) => [...x, path]) + setStore("activeFile", path) + }) + }, + activeFile, + setActiveFile(path: string | undefined) { + setStore("activeFile", path) + }, } })() diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 128c6adf..f344830a 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -1,28 +1,30 @@ import { FileIcon, Icon, IconButton, Tooltip } from "@/ui" -import { Tabs } from "@/ui/tabs" +import * as KobalteTabs from "@kobalte/core/tabs" import FileTree from "@/components/file-tree" import EditorPane from "@/components/editor-pane" import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { SelectDialog } from "@/components/select-dialog" -import { useLocal } from "@/context" -import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane" -import type { LocalFile } from "@/context/local" +import { useSync, useSDK, useLocal } from "@/context" +import type { LocalFile, TextSelection } from "@/context/local" import SessionList from "@/components/session-list" import SessionTimeline from "@/components/session-timeline" +import PromptForm, { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form" import { createStore } from "solid-js/store" import { getDirectory, getFilename } from "@/utils" +import { Select } from "@/components/select" +import { Tabs } from "@/ui/tabs" +import { Code } from "@/components/code" export default function Page() { const local = useLocal() + const sync = useSync() + const sdk = useSDK() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, modelSelectOpen: false, fileSelectOpen: false, }) - const layoutKey = "workspace" - const timelinePane = "timeline" - let inputRef: HTMLTextAreaElement | undefined = undefined const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" @@ -104,95 +106,231 @@ export default function Page() { } } + const handlePromptSubmit = async (prompt: PromptSubmitValue) => { + const existingSession = local.session.active() + let session = existingSession + if (!session) { + const created = await sdk.session.create() + session = created.data ?? undefined + } + if (!session) return + local.session.setActive(session.id) + + interface SubmissionAttachment { + path: string + selection?: TextSelection + label: string + } + + const createAttachmentKey = (path: string, selection?: TextSelection) => { + if (!selection) return path + return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}` + } + + const formatAttachmentLabel = (path: string, selection?: TextSelection) => { + if (!selection) return getFilename(path) + return `${getFilename(path)} (${selection.startLine}-${selection.endLine})` + } + + const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) + + const attachments = new Map() + + const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => { + if (!path) return + const key = createAttachmentKey(path, selection) + if (attachments.has(key)) return + attachments.set(key, { + path, + selection, + label: label ?? formatAttachmentLabel(path, selection), + }) + } + + const promptAttachments = prompt.parts.filter( + (part): part is Extract => part.kind === "attachment", + ) + + for (const part of promptAttachments) { + registerAttachment(part.path, part.selection, part.display) + } + + const activeFile = local.context.active() + if (activeFile) { + registerAttachment( + activeFile.path, + activeFile.selection, + activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), + ) + } + + for (const contextFile of local.context.all()) { + registerAttachment( + contextFile.path, + contextFile.selection, + formatAttachmentLabel(contextFile.path, contextFile.selection), + ) + } + + const attachmentParts = Array.from(attachments.values()).map((attachment) => { + const absolute = toAbsolutePath(attachment.path) + const query = attachment.selection + ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` + : "" + return { + type: "file" as const, + mime: "text/plain", + url: `file://${absolute}${query}`, + filename: getFilename(attachment.path), + source: { + type: "file" as const, + text: { + value: `@${attachment.label}`, + start: 0, + end: 0, + }, + path: absolute, + }, + } + }) + + await sdk.session.prompt({ + path: { id: session.id }, + body: { + agent: local.agent.current()!.name, + model: { + modelID: local.model.current()!.id, + providerID: local.model.current()!.provider.id, + }, + parts: [ + { + type: "text", + text: prompt.text, + }, + ...attachmentParts, + ], + }, + }) + } + return (
- - - -
- - - Files - - - Changes - - -
- - - - - No changes
} - > -
    - - {(path) => ( -
  • - -
  • +
    +
    + +
    +
    + + {(activeSession) => } + +
    + + } + > +
      + + {(path) => ( +
    • + +
    • + )} +
      +
    + +
    + +
    +
    + m.role === "user") ?? []} + label={(m) => sync.data.part[m.id].find((p) => p.type === "text")!.text} + class="bg-transparent! max-w-48 pl-0! text-text-muted!" + /> + + +
    +
    + +
    + setStore("fileSelectOpen", true)} + > + + + + + {(file) => ( + props.onTabClick(props.file)} + > +
    + + {getFilename(file.path)} +
    +
    )}
    -
- - - - - - + + + {(file) => ( + + + + )} + + + + setStore("modelSelectOpen", true)} - onInputRefChange={(element: HTMLTextAreaElement | null) => { + onInputRefChange={(element: HTMLTextAreaElement | undefined) => { inputRef = element ?? undefined }} /> - - -
- }> + )} onClose={() => setStore("fileSelectOpen", false)} - onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)} + onSelect={(x) => (x ? local.context.openFile(x) : undefined)} + // onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)} />
diff --git a/packages/desktop/src/utils/path.ts b/packages/desktop/src/utils/path.ts index 9c026ca4..3ae48cdb 100644 --- a/packages/desktop/src/utils/path.ts +++ b/packages/desktop/src/utils/path.ts @@ -1,6 +1,8 @@ export function getFilename(path: string) { - const parts = path.split("/") - return parts[parts.length - 1] + if (!path) return "" + const trimmed = path.replace(/[\/]+$/, "") + const parts = trimmed.split("/") + return parts[parts.length - 1] ?? "" } export function getDirectory(path: string) {