diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index ff0906f6b..ac8a6286c 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -57,6 +57,11 @@ import { CodeBreadcrumbs } from "./StatusItems"; import styles from "./EditorArea.module.css"; import { tx } from "@/platform/i18n"; +/** The documents of a project-files drag: folders (trailing `/`) are not opened as editors. */ +function droppedFiles(payload: string): string[] { + return payload.split("\n").filter((p) => !!p && !p.endsWith("/")); +} + export function EditorAreaView() { const layout = useTabs((s) => s.layout); const empty = useTabs((s) => Object.values(s.panes).every((p) => p.tabs.length === 0)); @@ -77,14 +82,14 @@ function EmptyEditorArea() { if (!el) return; return registerDropTarget(el, { accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type), - over: () => setOver(true), + over: (data) => setOver(data.type !== FILES_DRAG || droppedFiles(data.payload).length > 0), leave: () => setOver(false), drop: (data) => { setOver(false); const paneId = Object.keys(useTabs.getState().panes)[0]; if (!paneId) return; if (data.type === FILES_DRAG) { - void editorController().openFilesIn(data.payload.split("\n").filter(Boolean), paneId, "center"); + void editorController().openFilesIn(droppedFiles(data.payload), paneId, "center"); return; } if (data.type !== EDITOR_TAB_MIME) { @@ -253,14 +258,16 @@ function PaneView({ paneId }: { paneId: string }) { const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect()); return registerDropTarget(el, { accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type), - over: (data, x, y) => - setDropZone(data.type === EDITOR_TAB_MIME || data.type === FILES_DRAG ? zoneAt(x, y) : "center"), + over: (data, x, y) => { + if (data.type === FILES_DRAG) setDropZone(droppedFiles(data.payload).length ? zoneAt(x, y) : null); + else setDropZone(data.type === EDITOR_TAB_MIME ? zoneAt(x, y) : "center"); + }, leave: () => setDropZone(null), drop: (data, x, y) => { const zone = zoneAt(x, y); setDropZone(null); if (data.type === FILES_DRAG) { - void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone); + void ctrl.openFilesIn(droppedFiles(data.payload), paneId, zone); return; } if (data.type !== EDITOR_TAB_MIME) { diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index 62f1c1e21..220c9ab46 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -16,7 +16,35 @@ inset 0 0 0 1px var(--focus-ring), 0 0 0 1px var(--focus-ring); } +/* A file or folder dragged over it from the Project view. */ +.fileOver { + box-shadow: + inset 0 0 0 1px var(--focus-ring), + 0 0 0 1px var(--focus-ring); +} +.field { + position: relative; +} +/* Tags under the transparent textarea: same font, metrics and padding, so the caret + and selection of the real textarea line up with the painted text. */ +.mirror { + position: absolute; + inset: 0; + overflow: hidden; + padding: 10px 12px 2px; + font: inherit; + font-size: var(--fs-ui); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--fg-strong); + pointer-events: none; + -webkit-user-select: none; + user-select: none; +} .textarea { + position: relative; + z-index: 1; display: block; width: 100%; min-height: 44px; @@ -24,11 +52,13 @@ border: 0; outline: none; background: transparent; - color: var(--fg-strong); + color: transparent; + caret-color: var(--fg-strong); font: inherit; font-size: var(--fs-ui); line-height: 1.5; padding: 10px 12px 2px; + overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } @@ -38,6 +68,13 @@ .textarea::placeholder { color: var(--fg-faint); } +/* The textarea's own glyphs are transparent so the mirror above shows the tags; the + selection would then paint an empty highlight. Paint the selected glyphs here instead — + the opaque selection background covers the mirror underneath. */ +.textarea::selection { + background: var(--bg-editor-selection); + color: var(--fg-strong); +} .bar { display: flex; flex-wrap: wrap; @@ -58,9 +95,20 @@ display: flex; flex: none; align-items: center; - gap: 2px; + gap: 4px; margin-left: auto; } +/* Attach/reference buttons share the send button's 24px footprint and chrome. */ +.composerButton { + width: var(--h-control); + height: var(--h-control); + border-radius: var(--radius-small); + background: var(--bg-elev); + box-shadow: inset 0 0 0 1px var(--border); +} +.composerButton:hover:not(:disabled) { + background: var(--bg-hover); +} .spacer { flex: 1; } @@ -235,9 +283,11 @@ border-top: 1px solid var(--border); } @container agent-composer (max-width: 600px) { + /* Narrow: keep one row of icons next to the agent picker. The model/mode pickers + collapse into the settings menu, so nothing has to wrap onto a second line. */ .bar { - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: center; gap: 6px; } .toolbar { @@ -247,10 +297,22 @@ .toolbar > .configPicker { flex: 0 1 auto; min-width: 0; - max-width: calc((100% - 28px) / 2); } .configPicker > button { - width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* The agent picker keeps its icon and truncates its name instead of taking the row. */ + .toolbar > :first-child { + flex: 0 1 auto; + min-width: 0; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .inlineSetting { display: none; @@ -261,8 +323,13 @@ margin-left: auto; } .actions { - width: 100%; + width: auto; + flex: none; margin-left: 0; + justify-content: flex-end; + } + .actions .spacer { + display: none; } } .pickerValue { diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index cab5ec1bb..58a8029f0 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -3,7 +3,17 @@ * (project index search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle * the agent's mode. Its draft lives in the store under `id`. */ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; import clsx from "clsx"; import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings"; import { call, commands, isTauri } from "@/platform/ipc"; @@ -12,7 +22,7 @@ import { invokeLocal } from "@/platform/tauriCore"; import { useTx } from "@/platform/i18n"; import { basename } from "@/platform/project"; import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions"; -import { Icon, IconButton, Popup, fileIconName } from "@/ui"; +import { FILES_DRAG, Icon, IconButton, InlineTag, Popup, fileIconName, registerDropTarget } from "@/ui"; import { IMAGE_EXTENSIONS, MAX_IMAGE_BYTES, @@ -25,9 +35,11 @@ import { imageFromDataUrl, matchCommands, mentionOf, + messageTokens, modeOption, nextValue, recall, + toPrompt, type Completion, type FileRef, } from "./composerModel"; @@ -73,13 +85,16 @@ export function Composer(props: ComposerProps) { const draft = useAgents((s) => s.composers.get(id)); const text = draft?.text ?? ""; const images = draft?.images ?? []; + const tokens = useMemo(() => messageTokens(text), [text]); const [preview, setPreview] = useState(null); const ref = useRef(null); + const mirrorRef = useRef(null); const boxRef = useRef(null); const imageInput = useRef(null); const [completion, setCompletion] = useState(null); const [files, setFiles] = useState([]); const [active, setActive] = useState(0); + const composing = useRef(false); const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" }); useEffect(() => { @@ -103,6 +118,17 @@ export function Composer(props: ComposerProps) { el.style.height = `${Math.min(el.scrollHeight, MAX_PROMPT_HEIGHT)}px`; }, [text]); + // A textarea that scrolls loses content width to its scrollbar: give the mirror the same + // width and offset, or a long prompt wraps a line earlier and the painted text drifts. + const syncMirror = useCallback(() => { + const el = ref.current; + const mirror = mirrorRef.current; + if (!el || !mirror) return; + mirror.style.right = `${el.offsetWidth - el.clientWidth}px`; + mirror.scrollTop = el.scrollTop; + }, []); + useLayoutEffect(syncMirror, [text, syncMirror]); + // `@` completion: project file search, mapped into the task's directory. useEffect(() => { if (completion?.kind !== "file") return; @@ -131,8 +157,23 @@ export function Composer(props: ComposerProps) { return; } Promise.all([call(commands.findFiles(query, MAX_ITEMS)), call(commands.findDirs(query, MAX_ITEMS))]) - .then(([found, dirs]) => { + .then(async ([found, dirs]) => { if (!alive) return; + // A bare `@`: the index has nothing to match on, so list the root instead of + // showing no popup at all. + if (!query && found.length + dirs.length === 0) { + const top = await call(commands.pathCompletions(`${root}/_`, "")).catch(() => []); + if (!alive) return; + setFiles( + top.slice(0, MAX_ITEMS).map((f) => ({ + kind: "file", + rel: f.name, + dir: f.dir, + path: resolveTyped(root, f.name), + })), + ); + return; + } setFiles( mergeMatches(found, dirs, MAX_ITEMS).map((f) => ({ kind: "file", @@ -163,7 +204,7 @@ export function Composer(props: ComposerProps) { const setText = (value: string, caret?: number, restoreSelection = true) => { updateComposer(id, { text: value }); const c = caret ?? value.length; - setCompletion(completionAt(value, c)); + setCompletion(composing.current ? null : completionAt(value, c)); setActive(0); if (caret !== undefined && restoreSelection) { requestAnimationFrame(() => { @@ -189,15 +230,50 @@ export function Composer(props: ComposerProps) { const next = applyCompletion(text, completion, mentionOf(rel)); setText(next.text, next.caret); } else { + // Inserted in place: the order of the message is kept, and the text is tagged + // where it stands (see the mirror below the textarea). const next = applyCompletion(text, completion, `/${item.command.name}`); setText(next.text, next.caret); } setCompletion(null); }; - const addImages = (list: AgentImage[]) => { - if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] }); - }; + const [fileOver, setFileOver] = useState(false); + + /** Adds paths as `@` references (a file or a folder), each once. */ + const addPaths = useCallback( + (paths: readonly string[]) => { + const current = composerDraft(id); + const known = new Set(toPrompt(current).files); + const files = current.files.filter((f) => known.has(f.path)); + const refs: FileRef[] = []; + for (const raw of paths) { + const source = resolveTyped("", raw.replace(/\\/g, "/")); + const base = [root, projectRoot] + .filter(Boolean) + .map((path) => resolveTyped(path, ".")) + .find((path) => source === path || source.startsWith(`${path}/`)); + if (!base || !/^(\/|[A-Za-z]:[\\/])/.test(raw)) continue; + const rel = source === base ? "." : source.slice(base.length + 1); + const path = resolveTyped(root, rel); + if (known.has(path)) continue; + known.add(path); + refs.push({ path, rel }); + } + if (!refs.length) return; + const mentions = refs.map((r) => mentionOf(r.rel)).join(" "); + const glue = current.text && !/\s$/.test(current.text) ? " " : ""; + updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...files, ...refs] }); + }, + [id, root, projectRoot], + ); + + const addImages = useCallback( + (list: AgentImage[]) => { + if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] }); + }, + [id], + ); const readImageFiles = (picked: Iterable) => { for (const file of picked) { @@ -228,7 +304,7 @@ export function Composer(props: ComposerProps) { const box = boxRef.current?.getBoundingClientRect(); const { x, y } = nativeDropPoint(p.position); if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return; - const refs: FileRef[] = []; + const paths: string[] = []; for (const path of p.paths) { if (IMAGE_EXTENSIONS.test(path)) { void invokeLocal("fs_read_image", { path }).then((url) => { @@ -236,15 +312,10 @@ export function Composer(props: ComposerProps) { if (image) addImages([image]); }); } else { - refs.push({ path, rel: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path }); + paths.push(path); } } - if (refs.length) { - const current = composerDraft(id); - const mentions = refs.map((r) => mentionOf(r.rel)).join(" "); - const glue = current.text && !/\s$/.test(current.text) ? " " : ""; - updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...current.files, ...refs] }); - } + addPaths(paths); }) .then((u) => { if (disposed) u(); @@ -255,11 +326,52 @@ export function Composer(props: ComposerProps) { disposed = true; unlisten?.(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- re-subscribe only when the target changes - }, [id, root]); + }, [addPaths, addImages]); + + // Files and folders dragged from the Project view: the same `@` references. + useEffect(() => { + const el = boxRef.current; + if (!el) return; + return registerDropTarget(el, { + accepts: (type) => type === FILES_DRAG, + over: () => setFileOver(true), + leave: () => setFileOver(false), + drop: (data) => { + setFileOver(false); + addPaths(data.payload.split("\n").filter(Boolean)); + }, + }); + }, [addPaths]); const onKeyDown = (e: KeyboardEvent) => { - if (isImeKey(e.nativeEvent)) return; + if (composing.current || isImeKey(e.nativeEvent)) return; + const el = e.currentTarget; + if (!e.altKey && !e.metaKey && !e.ctrlKey && el.selectionStart === el.selectionEnd) { + const backward = e.key === "Backspace" || e.key === "ArrowLeft"; + const forward = e.key === "Delete" || e.key === "ArrowRight"; + if (backward || forward) { + let offset = 0; + for (const token of tokens) { + const end = offset + token.value.length; + const caret = el.selectionStart; + if (token.kind !== "text" && (backward ? caret > offset && caret <= end : caret >= offset && caret < end)) { + e.preventDefault(); + if (e.key === "Backspace" || e.key === "Delete") { + setText(text.slice(0, offset) + text.slice(end), offset); + } else { + const next = backward ? offset : end; + el.setSelectionRange( + e.shiftKey ? Math.min(caret, next) : next, + e.shiftKey ? Math.max(caret, next) : next, + ); + } + setCompletion(null); + return; + } + offset = end; + } + } + } if (open) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); @@ -315,7 +427,7 @@ export function Composer(props: ComposerProps) { return (
ref.current?.focus()} > @@ -386,35 +498,61 @@ export function Composer(props: ComposerProps) { setPreview(null)} />
)} -