import {
ChatAttachmentsList,
ChatFormActions,
- ChatFormContenteditable,
+ ChatFormContentEditable,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
>
{#if useContenteditable}
- <ChatFormContenteditable
+ <ChatFormContentEditable
class="px-5 py-1.5 md:pt-0 mb-0.5"
bind:this={inputRef}
bind:value
--- /dev/null
+<script lang="ts">
+ import { CODE_BLOCK } from '$lib/constants';
+ import { ColorMode } from '$lib/enums';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import type { ContentEditableToken } from '$lib/types';
+ import type { SourceHistoryEntry } from '$lib/utils';
+ import {
+ badgeAwareWordJump,
+ buildFragment,
+ domMatchesTokens,
+ highlightCode,
+ isIMEComposing,
+ isOffsetInCodeBlock,
+ leadingBadgeEdgeOffset,
+ rangeToTextOffset,
+ serializeContent,
+ SourceHistory,
+ stripBlockBoundaryLineBreaks,
+ syncCodeBlockHatches,
+ textOffsetToRange,
+ tokenizeContent
+ } from '$lib/utils';
+ import githubLightCss from 'highlight.js/styles/github.css?inline';
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import { mode } from 'mode-watcher';
+ import { onDestroy, onMount, untrack } from 'svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ onInput?: () => void;
+ onKeydown?: (event: KeyboardEvent) => void;
+ onPaste?: (event: ClipboardEvent) => void;
+ placeholder?: string;
+ value?: string;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ onInput,
+ onKeydown,
+ onPaste,
+ placeholder = 'Ask anything...',
+ value = $bindable('')
+ }: Props = $props();
+
+ let rootElement: HTMLDivElement | undefined = $state();
+ let lastEmittedValue = '';
+ let isComposing = $state(false);
+
+ // Undo/redo in source space: the imperative token rebuilds destroy the
+ // browser's native undo stack.
+ const history = new SourceHistory();
+
+ // Browsers disagree on what an empty contenteditable contains (`<br>`,
+ // `<div><br></div>`, or nothing), so emptiness is decided by the
+ // serialized source, not the DOM shape.
+ function syncEmptyState(serialized?: string) {
+ if (!rootElement) return;
+
+ const source = serialized ?? serializeContent(rootElement);
+
+ rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
+ }
+
+ function renderTokens(tokens: ContentEditableToken[]) {
+ if (!rootElement) return;
+
+ const caret = rangeToTextOffset(rootElement, safeRange());
+
+ // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+ rootElement.replaceChildren(buildFragment(tokens));
+
+ syncCodeBlockHatches(rootElement);
+ highlightCodeBlocks(rootElement);
+
+ restoreCaret(caret);
+ resizeHeight();
+ syncEmptyState();
+ }
+
+ // Last highlighted source segment per block element - typing inside
+ // a block re-highlights only when the segment actually changed.
+ const highlightedSegments = new WeakMap<HTMLElement, string>();
+
+ const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
+
+ /**
+ * Apply syntax highlighting to a code block element's CONTENT. The
+ * fence lines stay plain text, and the blank padding that
+ * `highlightCode` trims is re-added as plain text, so the element's
+ * textContent stays byte-exact with the source segment. Replaces
+ * the element's children - callers restore the caret afterwards.
+ * Returns false when nothing changed.
+ */
+ function highlightCodeBlockElement(el: HTMLElement): boolean {
+ const segment = el.textContent ?? '';
+
+ if (highlightedSegments.get(el) === segment) return false;
+
+ const open = CODE_BLOCK_OPEN_RE.exec(segment);
+
+ if (!open) return false;
+
+ const prefix = open[0];
+ const language = open[1].trim().split(/\s+/)[0] ?? '';
+ const content = segment.slice(prefix.length, -3);
+ const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
+ const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
+ const core = content.slice(leading.length, content.length - trailing.length);
+ // autoDetect off: re-guessing the language on every keystroke
+ // costs ~38ms a call and flickers while typing
+ const html = core ? highlightCode(core, language || 'text', false) : '';
+ const tpl = document.createElement('template');
+
+ tpl.innerHTML = html;
+
+ el.replaceChildren(
+ document.createTextNode(prefix + leading),
+ tpl.content.cloneNode(true),
+ document.createTextNode(trailing + '```')
+ );
+ highlightedSegments.set(el, segment);
+
+ return true;
+ }
+
+ function highlightCodeBlocks(root: HTMLElement) {
+ for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
+ highlightCodeBlockElement(el);
+ }
+ }
+
+ /**
+ * Re-highlight the code block the caret sits in after an edit.
+ * Skipped when the block's segment is unchanged since its last
+ * highlight, so edits outside blocks cost nothing.
+ */
+ function rehighlightCaretCodeBlock() {
+ if (!rootElement) return;
+
+ const range = safeRange();
+
+ if (!range) return;
+
+ let node: Node | null = range.startContainer;
+
+ if (node === rootElement) {
+ node = rootElement.childNodes[range.startOffset - 1] ?? null;
+ }
+
+ while (node && node !== rootElement) {
+ if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
+ const caret = rangeToTextOffset(rootElement, range);
+
+ if (highlightCodeBlockElement(node)) {
+ restoreCaret(caret);
+ }
+
+ return;
+ }
+
+ node = node.parentNode;
+ }
+ }
+
+ /**
+ * Is the caret inside a fenced code block region? Source-level
+ * (not DOM-level) so the still-OPEN fence counts too: while the
+ * user is typing a block, no closing ``` exists yet and the
+ * buffer is plain text with no block element to find. Root-level
+ * caret positions right at a closed block's edge (escape
+ * hatches, element boundaries restored by `textOffsetToRange`)
+ * resolve past the closing fence, so they count as OUTSIDE.
+ */
+ function caretInCodeBlock(): boolean {
+ if (!rootElement) return false;
+
+ return isOffsetInCodeBlock(
+ serializeContent(rootElement),
+ rangeToTextOffset(rootElement, safeRange())
+ );
+ }
+
+ /**
+ * hljs theme for the highlighted code blocks. Mirrors
+ * SyntaxHighlightedCode.svelte: one shared style element
+ * (deduped via the data attribute) swapped on mode change.
+ */
+ function loadHighlightTheme(isDark: boolean) {
+ document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
+
+ const style = document.createElement('style');
+
+ style.setAttribute('data-highlight-theme-preview', 'true');
+ style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+ document.head.appendChild(style);
+ }
+
+ $effect(() => {
+ loadHighlightTheme(mode.current === ColorMode.DARK);
+ });
+
+ function safeRange(): Range | null {
+ if (!rootElement) return null;
+
+ const selection = window.getSelection();
+
+ if (!selection || selection.rangeCount === 0) return null;
+
+ const range = selection.getRangeAt(0);
+
+ if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
+ return null;
+ }
+
+ return range;
+ }
+
+ function restoreCaret(offset: number, extend = false) {
+ if (!rootElement) return;
+
+ const target = textOffsetToRange(rootElement, offset);
+ const selection = window.getSelection();
+
+ if (!selection) return;
+
+ if (extend && selection.anchorNode) {
+ selection.setBaseAndExtent(
+ selection.anchorNode,
+ selection.anchorOffset,
+ target.startContainer,
+ target.startOffset
+ );
+
+ return;
+ }
+
+ selection.removeAllRanges();
+ selection.addRange(target);
+ }
+
+ function resizeHeight() {
+ if (!rootElement) return;
+
+ rootElement.style.height = 'auto';
+ rootElement.style.height = `${rootElement.scrollHeight}px`;
+ }
+
+ function recordHistory(newGroup: boolean) {
+ if (!rootElement) return;
+
+ history.push(
+ { caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
+ Date.now(),
+ newGroup
+ );
+ }
+
+ /**
+ * Re-emit the current markdown source value to the parent, then
+ * reconcile the DOM against the token stream: when a code span
+ * was just completed or broken, the token boundaries no longer
+ * match the element structure and the DOM is rebuilt (caret
+ * preserved through the source-offset mapping).
+ */
+ function processInput(inputType?: string) {
+ if (isComposing || !rootElement) return;
+
+ syncEmptyState();
+ resizeHeight();
+
+ // Shift+Enter right after a code block leaves an all-newline
+ // text node (the fence's separator line plus Chromium's
+ // artificial end-of-buffer line break). Strip both so the caret
+ // lands on the line directly below the block.
+ if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
+ const caret = rangeToTextOffset(rootElement, safeRange());
+
+ if (stripBlockBoundaryLineBreaks(rootElement)) {
+ restoreCaret(caret);
+ } else {
+ const source = serializeContent(rootElement);
+
+ let end = caret;
+
+ // the caret must end up after the inserted \n; some browsers
+ // leave it before (stuck at the end of the old line). A
+ // preceding \n means it already sits past the break
+ // (Chromium's artificial trailing newline) - leave it.
+ if (source[end] === '\n' && source[end - 1] !== '\n') {
+ end += 1;
+ restoreCaret(end);
+ }
+
+ // a line break at the buffer end renders only with a second,
+ // artificial trailing \n: a lone trailing \n is collapsed, so
+ // the new line is invisible and the next typed character
+ // consumes it. Append it when missing - unless the trailing
+ // \n doubles as a block's separator line (source ends with
+ // \n\n) or sits inside a block element.
+ let last = rootElement.lastChild;
+
+ while (last && last.nodeName === 'BR') last = last.previousSibling;
+
+ if (
+ end === source.length &&
+ source.endsWith('\n') &&
+ source[source.length - 2] !== '\n' &&
+ last?.nodeType === Node.TEXT_NODE
+ ) {
+ // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+ rootElement.appendChild(document.createTextNode('\n'));
+ restoreCaret(source.length);
+ resizeHeight();
+ }
+ }
+ }
+
+ syncCodeBlockHatches(rootElement);
+
+ const serialized = serializeContent(rootElement);
+
+ syncEmptyState(serialized);
+
+ if (serialized === lastEmittedValue) return;
+
+ // Plain typing/deletes coalesce per time window; structural edits
+ // (paste, newline, cut, autocorrect) start a new undo group.
+ recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
+
+ lastEmittedValue = serialized;
+ value = serialized;
+
+ // Rebuild when token boundaries shifted (a code span was just
+ // completed or broken) - the browser-owned text nodes cannot
+ // restyle themselves across element boundaries.
+ const tokens = tokenizeContent(serialized);
+
+ if (!domMatchesTokens(rootElement, tokens)) {
+ renderTokens(tokens);
+
+ // The rebuild can re-shape the DOM in a way that changes the
+ // serialization (e.g. Chromium merged trailing text into the
+ // block element and the rebuild splits it back out, which
+ // synthesizes the separator newline) - keep value in sync.
+ const reserialized = serializeContent(rootElement);
+
+ if (reserialized !== serialized) {
+ lastEmittedValue = reserialized;
+ value = reserialized;
+ }
+ } else {
+ rehighlightCaretCodeBlock();
+ }
+
+ onInput?.();
+ }
+
+ function handleInput(event: Event) {
+ processInput((event as InputEvent).inputType);
+ }
+
+ function handleCompositionStart() {
+ isComposing = true;
+ }
+
+ function handleCompositionEnd() {
+ isComposing = false;
+ processInput();
+ }
+
+ /**
+ * Insert a line break at the caret MANUALLY. Native Shift+Enter at
+ * the buffer end varies across browsers (a lone trailing \n that the
+ * renderer collapses, or a <br> that the hatch sync strips), which
+ * can leave the caret stuck on the old line; splitting the text node
+ * ourselves keeps the DOM shape - and the caret - deterministic.
+ * `processInput` then appends the artificial trailing \n when the
+ * break lands at the buffer end.
+ */
+ function insertLineBreak() {
+ if (!rootElement) return;
+
+ const range = safeRange();
+
+ if (!range) return;
+
+ if (!range.collapsed) {
+ range.deleteContents();
+ }
+
+ const container = range.startContainer;
+ const offset = range.startOffset;
+ const nl = document.createTextNode('\n');
+
+ // a break at the very end of a code block exits the block (the
+ // new line belongs below it, not inside)
+ let exitBlock: HTMLElement | null = null;
+
+ if (container.nodeType === Node.TEXT_NODE) {
+ let node: Node | null = container.parentNode;
+
+ while (node && node !== rootElement) {
+ if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
+ const tail = document.createRange();
+
+ tail.setStart(container, offset);
+ tail.setEnd(node, node.childNodes.length);
+
+ if (tail.toString().length === 0) exitBlock = node;
+
+ break;
+ }
+
+ node = node.parentNode;
+ }
+ }
+
+ if (exitBlock) {
+ exitBlock.after(nl);
+ } else if (container.nodeType === Node.TEXT_NODE) {
+ const text = container as Text;
+
+ if (offset === 0) {
+ text.before(nl);
+ } else if (offset === text.length) {
+ text.after(nl);
+ } else {
+ text.splitText(offset).before(nl);
+ }
+ } else {
+ container.insertBefore(nl, container.childNodes[offset] ?? null);
+ }
+
+ const selection = window.getSelection();
+ const after = document.createRange();
+
+ after.setStartAfter(nl);
+ after.collapse(true);
+ selection?.removeAllRanges();
+ selection?.addRange(after);
+
+ processInput('insertLineBreak');
+ }
+
+ /**
+ * Arrow escape to the line BEFORE a leading code block. Native
+ * caret movement has no position above a buffer-starting block,
+ * so a transient `<br>` hatch is created on demand: it gives the
+ * caret a visible line, is consumed by the first character typed
+ * on it, and is removed again when the caret leaves (see
+ * handleSelectionChange). Returns true when the caret was moved.
+ */
+ function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
+ if (!rootElement) return false;
+
+ // a hatch already exists - native movement handles it
+ if (rootElement.firstChild?.nodeName === 'BR') return false;
+
+ const first = rootElement.firstChild;
+
+ if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
+
+ const range = safeRange();
+
+ if (!range || !range.collapsed) return false;
+
+ // the caret must sit inside the block: on its very first
+ // character for ArrowLeft, anywhere on its first line for
+ // ArrowUp
+ if (!first.contains(range.startContainer)) return false;
+
+ const caret = rangeToTextOffset(rootElement, range);
+
+ if (key === 'ArrowLeft') {
+ if (caret !== 0) return false;
+ } else {
+ const firstLineEnd = (first.textContent ?? '').indexOf('\n');
+
+ if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
+ }
+
+ // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+ rootElement.prepend(document.createElement('br'));
+ restoreCaret(0, extend);
+
+ return true;
+ }
+
+ /**
+ * Remove the transient leading hatch once the caret leaves it.
+ * The hatch only exists to give the caret a line above a leading
+ * code block; with the caret anywhere else the empty line would
+ * just be visual noise. Typing on the hatch line consumes it via
+ * the stale-hatch removal in `syncCodeBlockHatches` instead (the
+ * new text node takes its place before the block).
+ */
+ function handleSelectionChange() {
+ if (!rootElement) return;
+
+ const first = rootElement.firstChild;
+
+ if (first?.nodeName !== 'BR') return;
+
+ const second = first.nextSibling;
+
+ if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
+
+ const range = safeRange();
+ const onHatch =
+ range !== null && range.startContainer === rootElement && range.startOffset === 0;
+
+ if (!onHatch) {
+ first.remove();
+ }
+ }
+
+ /**
+ * Undo/redo is replayed from source snapshots (the token rebuilds
+ * destroy the native undo stack). Arrow keys around badges are
+ * repaired locally: a badge is a non-editable island, so plain
+ * ArrowLeft after a leading badge has no native previous position
+ * and word jumps overshoot it by a word.
+ *
+ * Plain Enter inside a fenced code block (closed, or still open
+ * while being typed) acts as Shift+Enter and adds a line instead of
+ * submitting. ArrowLeft/ArrowUp at the edge of a leading code block
+ * create the transient before-block hatch.
+ */
+ function handleKeydown(event: KeyboardEvent) {
+ const mod = event.ctrlKey || event.metaKey;
+
+ if (mod && !event.altKey && !isComposing && rootElement) {
+ const key = event.key.toLowerCase();
+ const isUndo = key === 'z' && !event.shiftKey;
+ const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
+
+ if (isUndo || isRedo) {
+ event.preventDefault();
+ const current = {
+ caret: rangeToTextOffset(rootElement, safeRange()),
+ value: lastEmittedValue
+ };
+ const entry = isUndo ? history.undo(current) : history.redo(current);
+
+ if (entry) applyHistoryEntry(entry);
+
+ return;
+ }
+ }
+
+ if (
+ event.key === 'Enter' &&
+ event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ !isIMEComposing(event) &&
+ !disabled &&
+ !caretInCodeBlock() &&
+ safeRange()
+ ) {
+ // Own the break outside code blocks: native end-of-buffer
+ // behavior varies across browsers and can leave the caret
+ // stuck on the old line (see insertLineBreak).
+ event.preventDefault();
+ insertLineBreak();
+
+ return;
+ }
+
+ if (
+ event.key === 'Enter' &&
+ !event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ !isIMEComposing(event) &&
+ caretInCodeBlock()
+ ) {
+ // The native plain-Enter path must never run: it splits the
+ // buffer into `<div>` wrappers that `serializeContent` cannot
+ // see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
+ // text node) and fires `input` synchronously, so the usual
+ // re-tokenize/re-highlight follows.
+ event.preventDefault();
+ document.execCommand('insertLineBreak');
+
+ return;
+ }
+
+ if (
+ rootElement &&
+ (event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
+ event.preventDefault();
+
+ return;
+ }
+ }
+
+ if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
+ const isPlainLeft =
+ event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
+
+ if (isWordJump || isPlainLeft) {
+ const source = serializeContent(rootElement);
+ const caret = rangeToTextOffset(rootElement, safeRange());
+ const target = isWordJump
+ ? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
+ : leadingBadgeEdgeOffset(source, caret);
+
+ if (target !== null) {
+ event.preventDefault();
+ restoreCaret(target, event.shiftKey);
+
+ return;
+ }
+ }
+ }
+
+ onKeydown?.(event);
+ }
+
+ // lastEmittedValue is set before `value` so the sync effect treats the
+ // change as our own and does not re-render.
+ function applyHistoryEntry(entry: SourceHistoryEntry) {
+ if (!rootElement) return;
+
+ renderTokens(tokenizeContent(entry.value));
+ lastEmittedValue = entry.value;
+ value = entry.value;
+ onInput?.();
+ restoreCaret(entry.caret);
+ }
+
+ /**
+ * Plain-text paste. preventDefault + manual insertText keeps the
+ * browser from producing stray `<div>` wrappers mid-paste; insertText
+ * fires `input` synchronously, so `processInput` re-tokenizes the
+ * buffer and rebuilds when the pasted text carries badge or code
+ * tokens.
+ */
+ function handlePasteEvent(event: ClipboardEvent) {
+ const pasted = event.clipboardData?.getData('text/plain');
+
+ if (pasted && pasted.length > 0) {
+ event.preventDefault();
+
+ // Snap a collapsed caret through the offset mapping first: at
+ // element-boundary carets (e.g. right before a badge) Chromium's
+ // insertText can drop the preceding text node's trailing whitespace.
+ const range = safeRange();
+
+ if (rootElement && range && range.collapsed) {
+ restoreCaret(rangeToTextOffset(rootElement, range));
+ }
+
+ document.execCommand('insertText', false, pasted);
+ }
+ }
+
+ // The parent's paste handler runs first and preventDefaults when it
+ // consumes the event (files, quoted prompts, long text).
+ function handlePaste(event: ClipboardEvent) {
+ onPaste?.(event);
+
+ if (!event.defaultPrevented) {
+ handlePasteEvent(event);
+ }
+ }
+
+ // The selection as markdown SOURCE (each badge contributes its full
+ // `[name](file://...)` link), so copy/cut carry raw markdown and
+ // pasting back re-renders the badges. Null for collapsed/outside
+ // selections - native clipboard behavior is fine there.
+ function selectionSourceSlice(): { text: string; range: Range } | null {
+ if (!rootElement) return null;
+
+ const range = safeRange();
+
+ if (!range || range.collapsed) return null;
+
+ const startRange = range.cloneRange();
+
+ startRange.collapse(true);
+
+ const source = serializeContent(rootElement);
+ const start = rangeToTextOffset(rootElement, startRange);
+ const end = rangeToTextOffset(rootElement, range);
+
+ return { range, text: source.slice(start, end) };
+ }
+
+ function handleCopy(event: ClipboardEvent) {
+ const slice = selectionSourceSlice();
+
+ if (!slice) return;
+
+ event.clipboardData?.setData('text/plain', slice.text);
+ event.preventDefault();
+ }
+
+ function handleCut(event: ClipboardEvent) {
+ const slice = selectionSourceSlice();
+
+ if (!slice) return;
+
+ event.clipboardData?.setData('text/plain', slice.text);
+ event.preventDefault();
+
+ // preventDefault suppresses the native deletion, so remove the
+ // selection manually and re-emit.
+ slice.range.deleteContents();
+ processInput('deleteByCut');
+ }
+
+ onMount(() => {
+ // untrack: the DOM is managed manually from input events, so the
+ // initial render must not subscribe to the value.
+ renderTokens(tokenizeContent(untrack(() => value)));
+ lastEmittedValue = untrack(() => value ?? '');
+ resizeHeight();
+ syncEmptyState();
+ document.addEventListener('selectionchange', handleSelectionChange);
+
+ if (!isMobile.current) {
+ rootElement?.focus({ preventScroll: true });
+ }
+ });
+
+ onDestroy(() => {
+ document.removeEventListener('selectionchange', handleSelectionChange);
+ });
+
+ // External `value` updates. When incoming === lastEmittedValue the
+ // change came from our own input, so leave the DOM alone - the
+ // browser already owns the right shape.
+ $effect(() => {
+ const incoming = value ?? '';
+
+ if (incoming === lastEmittedValue) return;
+
+ recordHistory(true); // external edit (mention insert, clear, ...): own undo step
+ renderTokens(tokenizeContent(incoming));
+ lastEmittedValue = incoming;
+ });
+
+ export function getElement() {
+ return rootElement;
+ }
+
+ export function getCaretOffset(): number {
+ if (!rootElement) return 0;
+
+ return rangeToTextOffset(rootElement, safeRange());
+ }
+
+ // Focus first: `selection.addRange` requires it on some browsers.
+ export function setCaretOffset(offset: number) {
+ if (rootElement && rootElement !== document.activeElement) {
+ rootElement.focus({ preventScroll: true });
+ }
+
+ restoreCaret(offset);
+ }
+
+ export function focus() {
+ if (isMobile.current) return;
+
+ rootElement?.focus({ preventScroll: true });
+ }
+
+ export function resetHeight() {
+ if (rootElement) {
+ rootElement.style.height = '';
+ resizeHeight();
+ }
+ }
+</script>
+
+<div class="flex-1 {className}">
+ <div
+ bind:this={rootElement}
+ contenteditable={!disabled}
+ role="textbox"
+ aria-multiline="true"
+ aria-disabled={disabled}
+ aria-placeholder={placeholder}
+ data-placeholder={placeholder}
+ tabindex={disabled ? -1 : 0}
+ class={[
+ 'chat-form-contenteditable text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
+ disabled && 'cursor-not-allowed'
+ ]}
+ style="max-height: var(--max-message-height);"
+ oncompositionstart={handleCompositionStart}
+ oncompositionend={handleCompositionEnd}
+ oninput={handleInput}
+ onkeydown={handleKeydown}
+ onpaste={handlePaste}
+ oncopy={handleCopy}
+ oncut={handleCut}
+ ></div>
+</div>
+
+<style>
+ /* pre-wrap is load-bearing: without it Chromium collapses \n in
+ text nodes and converts them to spaces while typing */
+ .chat-form-contenteditable {
+ white-space: pre-wrap;
+ }
+
+ .chat-form-contenteditable:global([data-empty='true'])::before {
+ content: attr(data-placeholder);
+ color: var(--muted-foreground);
+ pointer-events: none;
+ }
+
+ /* Inline code - mirrors markdown-content.css */
+ .chat-form-contenteditable :global(code[data-code-token='inline']) {
+ background: var(--muted);
+ color: var(--muted-foreground);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ }
+
+ /* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
+ .chat-form-contenteditable :global(code[data-code-token='block']) {
+ display: block;
+ margin: 0.25rem 0;
+ padding: 0.75rem 1rem;
+ border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
+ border-radius: 0.75rem;
+ background: var(--code-background);
+ color: var(--code-foreground);
+ font-size: 0.875rem;
+ line-height: 1.3;
+ }
+</style>
+++ /dev/null
-<script lang="ts">
- import { CODE_BLOCK } from '$lib/constants';
- import { ColorMode } from '$lib/enums';
- import { isMobile } from '$lib/stores/viewport.svelte';
- import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
- import {
- badgeAwareWordJump,
- buildFragment,
- domMatchesTokens,
- highlightCode,
- isIMEComposing,
- isOffsetInCodeBlock,
- leadingBadgeEdgeOffset,
- rangeToTextOffset,
- serializeContent,
- SourceHistory,
- stripBlockBoundaryLineBreaks,
- syncCodeBlockHatches,
- textOffsetToRange,
- tokenizeContent
- } from '$lib/utils';
- import githubLightCss from 'highlight.js/styles/github.css?inline';
- import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
- import { mode } from 'mode-watcher';
- import { onDestroy, onMount, untrack } from 'svelte';
-
- interface Props {
- class?: string;
- disabled?: boolean;
- onInput?: () => void;
- onKeydown?: (event: KeyboardEvent) => void;
- onPaste?: (event: ClipboardEvent) => void;
- placeholder?: string;
- value?: string;
- }
-
- let {
- class: className = '',
- disabled = false,
- onInput,
- onKeydown,
- onPaste,
- placeholder = 'Ask anything...',
- value = $bindable('')
- }: Props = $props();
-
- let rootElement: HTMLDivElement | undefined = $state();
- let lastEmittedValue = '';
- let isComposing = $state(false);
-
- // Undo/redo in source space: the imperative token rebuilds destroy the
- // browser's native undo stack.
- const history = new SourceHistory();
-
- // Browsers disagree on what an empty contenteditable contains (`<br>`,
- // `<div><br></div>`, or nothing), so emptiness is decided by the
- // serialized source, not the DOM shape.
- function syncEmptyState(serialized?: string) {
- if (!rootElement) return;
-
- const source = serialized ?? serializeContent(rootElement);
-
- rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
- }
-
- function renderTokens(tokens: ContentToken[]) {
- if (!rootElement) return;
-
- const caret = rangeToTextOffset(rootElement, safeRange());
-
- // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
- rootElement.replaceChildren(buildFragment(tokens));
-
- syncCodeBlockHatches(rootElement);
- highlightCodeBlocks(rootElement);
-
- restoreCaret(caret);
- resizeHeight();
- syncEmptyState();
- }
-
- // Last highlighted source segment per block element - typing inside
- // a block re-highlights only when the segment actually changed.
- const highlightedSegments = new WeakMap<HTMLElement, string>();
-
- const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
-
- /**
- * Apply syntax highlighting to a code block element's CONTENT. The
- * fence lines stay plain text, and the blank padding that
- * `highlightCode` trims is re-added as plain text, so the element's
- * textContent stays byte-exact with the source segment. Replaces
- * the element's children - callers restore the caret afterwards.
- * Returns false when nothing changed.
- */
- function highlightCodeBlockElement(el: HTMLElement): boolean {
- const segment = el.textContent ?? '';
-
- if (highlightedSegments.get(el) === segment) return false;
-
- const open = CODE_BLOCK_OPEN_RE.exec(segment);
-
- if (!open) return false;
-
- const prefix = open[0];
- const language = open[1].trim().split(/\s+/)[0] ?? '';
- const content = segment.slice(prefix.length, -3);
- const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
- const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
- const core = content.slice(leading.length, content.length - trailing.length);
- // autoDetect off: re-guessing the language on every keystroke
- // costs ~38ms a call and flickers while typing
- const html = core ? highlightCode(core, language || 'text', false) : '';
- const tpl = document.createElement('template');
-
- tpl.innerHTML = html;
-
- el.replaceChildren(
- document.createTextNode(prefix + leading),
- tpl.content.cloneNode(true),
- document.createTextNode(trailing + '```')
- );
- highlightedSegments.set(el, segment);
-
- return true;
- }
-
- function highlightCodeBlocks(root: HTMLElement) {
- for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
- highlightCodeBlockElement(el);
- }
- }
-
- /**
- * Re-highlight the code block the caret sits in after an edit.
- * Skipped when the block's segment is unchanged since its last
- * highlight, so edits outside blocks cost nothing.
- */
- function rehighlightCaretCodeBlock() {
- if (!rootElement) return;
-
- const range = safeRange();
-
- if (!range) return;
-
- let node: Node | null = range.startContainer;
-
- if (node === rootElement) {
- node = rootElement.childNodes[range.startOffset - 1] ?? null;
- }
-
- while (node && node !== rootElement) {
- if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
- const caret = rangeToTextOffset(rootElement, range);
-
- if (highlightCodeBlockElement(node)) {
- restoreCaret(caret);
- }
-
- return;
- }
-
- node = node.parentNode;
- }
- }
-
- /**
- * Is the caret inside a fenced code block region? Source-level
- * (not DOM-level) so the still-OPEN fence counts too: while the
- * user is typing a block, no closing ``` exists yet and the
- * buffer is plain text with no block element to find. Root-level
- * caret positions right at a closed block's edge (escape
- * hatches, element boundaries restored by `textOffsetToRange`)
- * resolve past the closing fence, so they count as OUTSIDE.
- */
- function caretInCodeBlock(): boolean {
- if (!rootElement) return false;
-
- return isOffsetInCodeBlock(
- serializeContent(rootElement),
- rangeToTextOffset(rootElement, safeRange())
- );
- }
-
- /**
- * hljs theme for the highlighted code blocks. Mirrors
- * SyntaxHighlightedCode.svelte: one shared style element
- * (deduped via the data attribute) swapped on mode change.
- */
- function loadHighlightTheme(isDark: boolean) {
- document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
-
- const style = document.createElement('style');
-
- style.setAttribute('data-highlight-theme-preview', 'true');
- style.textContent = isDark ? githubDarkCss : githubLightCss;
-
- document.head.appendChild(style);
- }
-
- $effect(() => {
- loadHighlightTheme(mode.current === ColorMode.DARK);
- });
-
- function safeRange(): Range | null {
- if (!rootElement) return null;
-
- const selection = window.getSelection();
-
- if (!selection || selection.rangeCount === 0) return null;
-
- const range = selection.getRangeAt(0);
-
- if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
- return null;
- }
-
- return range;
- }
-
- function restoreCaret(offset: number, extend = false) {
- if (!rootElement) return;
-
- const target = textOffsetToRange(rootElement, offset);
- const selection = window.getSelection();
-
- if (!selection) return;
-
- if (extend && selection.anchorNode) {
- selection.setBaseAndExtent(
- selection.anchorNode,
- selection.anchorOffset,
- target.startContainer,
- target.startOffset
- );
-
- return;
- }
-
- selection.removeAllRanges();
- selection.addRange(target);
- }
-
- function resizeHeight() {
- if (!rootElement) return;
-
- rootElement.style.height = 'auto';
- rootElement.style.height = `${rootElement.scrollHeight}px`;
- }
-
- function recordHistory(newGroup: boolean) {
- if (!rootElement) return;
-
- history.push(
- { caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
- Date.now(),
- newGroup
- );
- }
-
- /**
- * Re-emit the current markdown source value to the parent, then
- * reconcile the DOM against the token stream: when a code span
- * was just completed or broken, the token boundaries no longer
- * match the element structure and the DOM is rebuilt (caret
- * preserved through the source-offset mapping).
- */
- function processInput(inputType?: string) {
- if (isComposing || !rootElement) return;
-
- syncEmptyState();
- resizeHeight();
-
- // Shift+Enter right after a code block leaves an all-newline
- // text node (the fence's separator line plus Chromium's
- // artificial end-of-buffer line break). Strip both so the caret
- // lands on the line directly below the block.
- if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
- const caret = rangeToTextOffset(rootElement, safeRange());
-
- if (stripBlockBoundaryLineBreaks(rootElement)) {
- restoreCaret(caret);
- } else {
- const source = serializeContent(rootElement);
-
- let end = caret;
-
- // the caret must end up after the inserted \n; some browsers
- // leave it before (stuck at the end of the old line). A
- // preceding \n means it already sits past the break
- // (Chromium's artificial trailing newline) - leave it.
- if (source[end] === '\n' && source[end - 1] !== '\n') {
- end += 1;
- restoreCaret(end);
- }
-
- // a line break at the buffer end renders only with a second,
- // artificial trailing \n: a lone trailing \n is collapsed, so
- // the new line is invisible and the next typed character
- // consumes it. Append it when missing - unless the trailing
- // \n doubles as a block's separator line (source ends with
- // \n\n) or sits inside a block element.
- let last = rootElement.lastChild;
-
- while (last && last.nodeName === 'BR') last = last.previousSibling;
-
- if (
- end === source.length &&
- source.endsWith('\n') &&
- source[source.length - 2] !== '\n' &&
- last?.nodeType === Node.TEXT_NODE
- ) {
- // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
- rootElement.appendChild(document.createTextNode('\n'));
- restoreCaret(source.length);
- resizeHeight();
- }
- }
- }
-
- syncCodeBlockHatches(rootElement);
-
- const serialized = serializeContent(rootElement);
-
- syncEmptyState(serialized);
-
- if (serialized === lastEmittedValue) return;
-
- // Plain typing/deletes coalesce per time window; structural edits
- // (paste, newline, cut, autocorrect) start a new undo group.
- recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
-
- lastEmittedValue = serialized;
- value = serialized;
-
- // Rebuild when token boundaries shifted (a code span was just
- // completed or broken) - the browser-owned text nodes cannot
- // restyle themselves across element boundaries.
- const tokens = tokenizeContent(serialized);
-
- if (!domMatchesTokens(rootElement, tokens)) {
- renderTokens(tokens);
-
- // The rebuild can re-shape the DOM in a way that changes the
- // serialization (e.g. Chromium merged trailing text into the
- // block element and the rebuild splits it back out, which
- // synthesizes the separator newline) - keep value in sync.
- const reserialized = serializeContent(rootElement);
-
- if (reserialized !== serialized) {
- lastEmittedValue = reserialized;
- value = reserialized;
- }
- } else {
- rehighlightCaretCodeBlock();
- }
-
- onInput?.();
- }
-
- function handleInput(event: Event) {
- processInput((event as InputEvent).inputType);
- }
-
- function handleCompositionStart() {
- isComposing = true;
- }
-
- function handleCompositionEnd() {
- isComposing = false;
- processInput();
- }
-
- /**
- * Insert a line break at the caret MANUALLY. Native Shift+Enter at
- * the buffer end varies across browsers (a lone trailing \n that the
- * renderer collapses, or a <br> that the hatch sync strips), which
- * can leave the caret stuck on the old line; splitting the text node
- * ourselves keeps the DOM shape - and the caret - deterministic.
- * `processInput` then appends the artificial trailing \n when the
- * break lands at the buffer end.
- */
- function insertLineBreak() {
- if (!rootElement) return;
-
- const range = safeRange();
-
- if (!range) return;
-
- if (!range.collapsed) {
- range.deleteContents();
- }
-
- const container = range.startContainer;
- const offset = range.startOffset;
- const nl = document.createTextNode('\n');
-
- // a break at the very end of a code block exits the block (the
- // new line belongs below it, not inside)
- let exitBlock: HTMLElement | null = null;
-
- if (container.nodeType === Node.TEXT_NODE) {
- let node: Node | null = container.parentNode;
-
- while (node && node !== rootElement) {
- if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
- const tail = document.createRange();
-
- tail.setStart(container, offset);
- tail.setEnd(node, node.childNodes.length);
-
- if (tail.toString().length === 0) exitBlock = node;
-
- break;
- }
-
- node = node.parentNode;
- }
- }
-
- if (exitBlock) {
- exitBlock.after(nl);
- } else if (container.nodeType === Node.TEXT_NODE) {
- const text = container as Text;
-
- if (offset === 0) {
- text.before(nl);
- } else if (offset === text.length) {
- text.after(nl);
- } else {
- text.splitText(offset).before(nl);
- }
- } else {
- container.insertBefore(nl, container.childNodes[offset] ?? null);
- }
-
- const selection = window.getSelection();
- const after = document.createRange();
-
- after.setStartAfter(nl);
- after.collapse(true);
- selection?.removeAllRanges();
- selection?.addRange(after);
-
- processInput('insertLineBreak');
- }
-
- /**
- * Arrow escape to the line BEFORE a leading code block. Native
- * caret movement has no position above a buffer-starting block,
- * so a transient `<br>` hatch is created on demand: it gives the
- * caret a visible line, is consumed by the first character typed
- * on it, and is removed again when the caret leaves (see
- * handleSelectionChange). Returns true when the caret was moved.
- */
- function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
- if (!rootElement) return false;
-
- // a hatch already exists - native movement handles it
- if (rootElement.firstChild?.nodeName === 'BR') return false;
-
- const first = rootElement.firstChild;
-
- if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
-
- const range = safeRange();
-
- if (!range || !range.collapsed) return false;
-
- // the caret must sit inside the block: on its very first
- // character for ArrowLeft, anywhere on its first line for
- // ArrowUp
- if (!first.contains(range.startContainer)) return false;
-
- const caret = rangeToTextOffset(rootElement, range);
-
- if (key === 'ArrowLeft') {
- if (caret !== 0) return false;
- } else {
- const firstLineEnd = (first.textContent ?? '').indexOf('\n');
-
- if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
- }
-
- // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
- rootElement.prepend(document.createElement('br'));
- restoreCaret(0, extend);
-
- return true;
- }
-
- /**
- * Remove the transient leading hatch once the caret leaves it.
- * The hatch only exists to give the caret a line above a leading
- * code block; with the caret anywhere else the empty line would
- * just be visual noise. Typing on the hatch line consumes it via
- * the stale-hatch removal in `syncCodeBlockHatches` instead (the
- * new text node takes its place before the block).
- */
- function handleSelectionChange() {
- if (!rootElement) return;
-
- const first = rootElement.firstChild;
-
- if (first?.nodeName !== 'BR') return;
-
- const second = first.nextSibling;
-
- if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
-
- const range = safeRange();
- const onHatch =
- range !== null && range.startContainer === rootElement && range.startOffset === 0;
-
- if (!onHatch) {
- first.remove();
- }
- }
-
- /**
- * Undo/redo is replayed from source snapshots (the token rebuilds
- * destroy the native undo stack). Arrow keys around badges are
- * repaired locally: a badge is a non-editable island, so plain
- * ArrowLeft after a leading badge has no native previous position
- * and word jumps overshoot it by a word.
- *
- * Plain Enter inside a fenced code block (closed, or still open
- * while being typed) acts as Shift+Enter and adds a line instead of
- * submitting. ArrowLeft/ArrowUp at the edge of a leading code block
- * create the transient before-block hatch.
- */
- function handleKeydown(event: KeyboardEvent) {
- const mod = event.ctrlKey || event.metaKey;
-
- if (mod && !event.altKey && !isComposing && rootElement) {
- const key = event.key.toLowerCase();
- const isUndo = key === 'z' && !event.shiftKey;
- const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
-
- if (isUndo || isRedo) {
- event.preventDefault();
- const current = {
- caret: rangeToTextOffset(rootElement, safeRange()),
- value: lastEmittedValue
- };
- const entry = isUndo ? history.undo(current) : history.redo(current);
-
- if (entry) applyHistoryEntry(entry);
-
- return;
- }
- }
-
- if (
- event.key === 'Enter' &&
- event.shiftKey &&
- !event.ctrlKey &&
- !event.metaKey &&
- !event.altKey &&
- !isIMEComposing(event) &&
- !disabled &&
- !caretInCodeBlock() &&
- safeRange()
- ) {
- // Own the break outside code blocks: native end-of-buffer
- // behavior varies across browsers and can leave the caret
- // stuck on the old line (see insertLineBreak).
- event.preventDefault();
- insertLineBreak();
-
- return;
- }
-
- if (
- event.key === 'Enter' &&
- !event.shiftKey &&
- !event.ctrlKey &&
- !event.metaKey &&
- !event.altKey &&
- !isIMEComposing(event) &&
- caretInCodeBlock()
- ) {
- // The native plain-Enter path must never run: it splits the
- // buffer into `<div>` wrappers that `serializeContent` cannot
- // see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
- // text node) and fires `input` synchronously, so the usual
- // re-tokenize/re-highlight follows.
- event.preventDefault();
- document.execCommand('insertLineBreak');
-
- return;
- }
-
- if (
- rootElement &&
- (event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
- !event.altKey &&
- !event.ctrlKey &&
- !event.metaKey
- ) {
- if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
- event.preventDefault();
-
- return;
- }
- }
-
- if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
- const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
- const isPlainLeft =
- event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
-
- if (isWordJump || isPlainLeft) {
- const source = serializeContent(rootElement);
- const caret = rangeToTextOffset(rootElement, safeRange());
- const target = isWordJump
- ? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
- : leadingBadgeEdgeOffset(source, caret);
-
- if (target !== null) {
- event.preventDefault();
- restoreCaret(target, event.shiftKey);
-
- return;
- }
- }
- }
-
- onKeydown?.(event);
- }
-
- // lastEmittedValue is set before `value` so the sync effect treats the
- // change as our own and does not re-render.
- function applyHistoryEntry(entry: SourceHistoryEntry) {
- if (!rootElement) return;
-
- renderTokens(tokenizeContent(entry.value));
- lastEmittedValue = entry.value;
- value = entry.value;
- onInput?.();
- restoreCaret(entry.caret);
- }
-
- /**
- * Plain-text paste. preventDefault + manual insertText keeps the
- * browser from producing stray `<div>` wrappers mid-paste; insertText
- * fires `input` synchronously, so `processInput` re-tokenizes the
- * buffer and rebuilds when the pasted text carries badge or code
- * tokens.
- */
- function handlePasteEvent(event: ClipboardEvent) {
- const pasted = event.clipboardData?.getData('text/plain');
-
- if (pasted && pasted.length > 0) {
- event.preventDefault();
-
- // Snap a collapsed caret through the offset mapping first: at
- // element-boundary carets (e.g. right before a badge) Chromium's
- // insertText can drop the preceding text node's trailing whitespace.
- const range = safeRange();
-
- if (rootElement && range && range.collapsed) {
- restoreCaret(rangeToTextOffset(rootElement, range));
- }
-
- document.execCommand('insertText', false, pasted);
- }
- }
-
- // The parent's paste handler runs first and preventDefaults when it
- // consumes the event (files, quoted prompts, long text).
- function handlePaste(event: ClipboardEvent) {
- onPaste?.(event);
-
- if (!event.defaultPrevented) {
- handlePasteEvent(event);
- }
- }
-
- // The selection as markdown SOURCE (each badge contributes its full
- // `[name](file://...)` link), so copy/cut carry raw markdown and
- // pasting back re-renders the badges. Null for collapsed/outside
- // selections - native clipboard behavior is fine there.
- function selectionSourceSlice(): { text: string; range: Range } | null {
- if (!rootElement) return null;
-
- const range = safeRange();
-
- if (!range || range.collapsed) return null;
-
- const startRange = range.cloneRange();
-
- startRange.collapse(true);
-
- const source = serializeContent(rootElement);
- const start = rangeToTextOffset(rootElement, startRange);
- const end = rangeToTextOffset(rootElement, range);
-
- return { range, text: source.slice(start, end) };
- }
-
- function handleCopy(event: ClipboardEvent) {
- const slice = selectionSourceSlice();
-
- if (!slice) return;
-
- event.clipboardData?.setData('text/plain', slice.text);
- event.preventDefault();
- }
-
- function handleCut(event: ClipboardEvent) {
- const slice = selectionSourceSlice();
-
- if (!slice) return;
-
- event.clipboardData?.setData('text/plain', slice.text);
- event.preventDefault();
-
- // preventDefault suppresses the native deletion, so remove the
- // selection manually and re-emit.
- slice.range.deleteContents();
- processInput('deleteByCut');
- }
-
- onMount(() => {
- // untrack: the DOM is managed manually from input events, so the
- // initial render must not subscribe to the value.
- renderTokens(tokenizeContent(untrack(() => value)));
- lastEmittedValue = untrack(() => value ?? '');
- resizeHeight();
- syncEmptyState();
- document.addEventListener('selectionchange', handleSelectionChange);
-
- if (!isMobile.current) {
- rootElement?.focus({ preventScroll: true });
- }
- });
-
- onDestroy(() => {
- document.removeEventListener('selectionchange', handleSelectionChange);
- });
-
- // External `value` updates. When incoming === lastEmittedValue the
- // change came from our own input, so leave the DOM alone - the
- // browser already owns the right shape.
- $effect(() => {
- const incoming = value ?? '';
-
- if (incoming === lastEmittedValue) return;
-
- recordHistory(true); // external edit (mention insert, clear, ...): own undo step
- renderTokens(tokenizeContent(incoming));
- lastEmittedValue = incoming;
- });
-
- export function getElement() {
- return rootElement;
- }
-
- export function getCaretOffset(): number {
- if (!rootElement) return 0;
-
- return rangeToTextOffset(rootElement, safeRange());
- }
-
- // Focus first: `selection.addRange` requires it on some browsers.
- export function setCaretOffset(offset: number) {
- if (rootElement && rootElement !== document.activeElement) {
- rootElement.focus({ preventScroll: true });
- }
-
- restoreCaret(offset);
- }
-
- export function focus() {
- if (isMobile.current) return;
-
- rootElement?.focus({ preventScroll: true });
- }
-
- export function resetHeight() {
- if (rootElement) {
- rootElement.style.height = '';
- resizeHeight();
- }
- }
-</script>
-
-<div class="flex-1 {className}">
- <div
- bind:this={rootElement}
- contenteditable={!disabled}
- role="textbox"
- aria-multiline="true"
- aria-disabled={disabled}
- aria-placeholder={placeholder}
- data-placeholder={placeholder}
- tabindex={disabled ? -1 : 0}
- class={[
- 'chat-form-contenteditable text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
- disabled && 'cursor-not-allowed'
- ]}
- style="max-height: var(--max-message-height);"
- oncompositionstart={handleCompositionStart}
- oncompositionend={handleCompositionEnd}
- oninput={handleInput}
- onkeydown={handleKeydown}
- onpaste={handlePaste}
- oncopy={handleCopy}
- oncut={handleCut}
- ></div>
-</div>
-
-<style>
- /* pre-wrap is load-bearing: without it Chromium collapses \n in
- text nodes and converts them to spaces while typing */
- .chat-form-contenteditable {
- white-space: pre-wrap;
- }
-
- .chat-form-contenteditable:global([data-empty='true'])::before {
- content: attr(data-placeholder);
- color: var(--muted-foreground);
- pointer-events: none;
- }
-
- /* Inline code - mirrors markdown-content.css */
- .chat-form-contenteditable :global(code[data-code-token='inline']) {
- background: var(--muted);
- color: var(--muted-foreground);
- padding: 0.125rem 0.375rem;
- border-radius: 0.375rem;
- font-size: 0.875rem;
- }
-
- /* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
- .chat-form-contenteditable :global(code[data-code-token='block']) {
- display: block;
- margin: 0.25rem 0;
- padding: 0.75rem 1rem;
- border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
- border-radius: 0.75rem;
- background: var(--code-background);
- color: var(--code-foreground);
- font-size: 0.875rem;
- line-height: 1.3;
- }
-</style>
<script lang="ts">
- import type { ColorLevel } from './context-gauge';
import { colorLevelTextClass } from './context-gauge';
+ import type { ColorLevel } from '$lib/enums';
interface Props {
percent: number | null;
-export type ColorLevel = 'ok' | 'warning' | 'critical' | 'neutral';
+import { ColorLevel } from '$lib/enums';
const WARNING_THRESHOLD = 80;
const CRITICAL_THRESHOLD = 95;
export function colorLevelFromPercent(percent: number | null): ColorLevel {
- if (percent === null) return 'neutral';
+ if (percent === null) return ColorLevel.NEUTRAL;
- if (percent >= CRITICAL_THRESHOLD) return 'critical';
+ if (percent >= CRITICAL_THRESHOLD) return ColorLevel.CRITICAL;
- if (percent >= WARNING_THRESHOLD) return 'warning';
+ if (percent >= WARNING_THRESHOLD) return ColorLevel.WARNING;
- return 'ok';
+ return ColorLevel.OK;
}
export function colorLevelTextClass(level: ColorLevel): string {
switch (level) {
- case 'critical':
+ case ColorLevel.CRITICAL:
return 'text-red-400';
- case 'warning':
+ case ColorLevel.WARNING:
return 'text-amber-400';
- case 'ok':
+ case ColorLevel.OK:
return 'text-muted-foreground';
default:
return 'text-muted-foreground';
export function colorLevelBgClass(level: ColorLevel): string {
switch (level) {
- case 'critical':
+ case ColorLevel.CRITICAL:
return 'bg-red-500';
- case 'warning':
+ case ColorLevel.WARNING:
return 'bg-amber-500';
- case 'ok':
+ case ColorLevel.OK:
return 'bg-green-500';
default:
return 'bg-muted';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
- import type { FileMentionEntry } from '$lib/types';
- import { abbreviateHome, type GlobEntryResult, runGlobSearchWithChildren } from '$lib/utils';
+ import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
+ import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
/**
* Floating file/folder mention picker. The chat input is the search
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
+ import type { GlobEntry } from '$lib/types';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
- type GlobEntry,
joinPath,
lastPathSegment,
runGlobSearchWithChildren
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
import { BuiltInTool } from '$lib/enums';
+ import type { AgenticSection } from '$lib/types';
import type { DatabaseMessageExtra } from '$lib/types';
- import {
- type AgenticSection,
- extractSearchQuery,
- extractSearchResults,
- isWebSearchToolName
- } from '$lib/utils';
+ import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils';
interface Props {
section: AgenticSection;
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { AttachmentType, FileTypeText, MimeTypeAudio, ToolResultKind } from '$lib/enums';
- import type { DatabaseMessageExtra } from '$lib/types';
+ import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types';
import {
- type AgenticSection,
classifyToolResult,
formatJsonPretty,
getBuiltinToolUi,
- parseToolResultWithMedia,
- type ToolResultLine
+ parseToolResultWithMedia
} from '$lib/utils';
import { createBase64DataUrl } from '$lib/utils/data-url';
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { abbreviateHome, type AgenticSection, computeLineDiff, prefixFor } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { abbreviateHome, computeLineDiff, prefixFor } from '$lib/utils';
interface Props {
section: AgenticSection;
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
+ import { AttachmentType } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
+ import type { AgenticSection, ToolResultLine } from '$lib/types';
import type { DatabaseMessageExtra } from '$lib/types';
import {
abbreviateHome,
- type AgenticSection,
type ExecShellExitStatus,
highlightCode,
isExitCodeSummaryLine,
parseExecShellCommandError,
parseExecShellCommandExitStatus,
- parseToolResultWithMedia,
- type ToolResultLine
+ parseToolResultWithMedia
} from '$lib/utils';
interface Props {
>
{#each outputLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
- {#if line.media}
+ {#if line.media?.type === AttachmentType.IMAGE}
<img
src={line.media.base64Url}
alt={line.media.name}
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
<script lang="ts">
import { Clock, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
- import type { AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
interface Props {
section: AgenticSection;
import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { CODE_BLOCK, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
- import { type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
interface Props {
section: AgenticSection;
import { ATTACHMENT_SAVED_REGEX } from '$lib/constants/agentic.constants';
import { AttachmentType, MimeTypeAudio } from '$lib/enums';
import type { DatabaseMessageExtraAudioFile, DatabaseMessageExtraImageFile } from '$lib/types';
- import { type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
import { createBase64DataUrl } from '$lib/utils/data-url';
interface Props {
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
- import { type AgenticSection, getBuiltinToolUi } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { getBuiltinToolUi } from '$lib/utils';
interface Props {
section: AgenticSection;
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { AgenticSection, SearchResult } from '$lib/types';
import {
- type AgenticSection,
extractSearchQuery,
extractSearchResults,
faviconForUrl,
- sanitizeExternalUrl,
- type SearchResult
+ sanitizeExternalUrl
} from '$lib/utils';
interface Props {
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
+ import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import { type AgenticSection, type BuiltinToolUiEntry, getBuiltinToolUi } from '$lib/utils';
+ import type { AgenticSection, BuiltinToolUiEntry } from '$lib/types';
+ import { getBuiltinToolUi } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
// stay focused on its own format quirks.
import { BuiltInTool } from '$lib/enums';
-import type { AgenticSection } from '$lib/utils/agentic';
+import type { AgenticSection } from '$lib/types/agentic';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
/**
import { parseToolArgs } from './_shared';
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
-import { type AgenticSection, tryParseToolResultObject } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
+import { tryParseToolResultObject } from '$lib/utils';
export type EditFileEdit = {
oldText: string;
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
-import type { AgenticSection } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
export type ExecShellCommandMeta = {
command: string;
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
-import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
+import { splitSearchSummaryList } from '$lib/utils';
export type FileGlobSearchMeta = {
path: string;
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
-import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
+import { splitSearchSummaryList } from '$lib/utils';
export type GrepSearchMatch = {
file: string;
import { parseToolArgs } from './_shared';
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
-import { type AgenticSection, getFileTypeByExtension } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
+import { getFileTypeByExtension } from '$lib/utils';
export type ReadFileMeta = {
fileName: string;
PREFIX_SIZE,
READ_MEDIA_SIZE_REGEX
} from '$lib/constants/read-media';
-import type { AgenticSection } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
export interface ReadMediaMeta {
fileName: string;
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
-import type { AgenticSection } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
export type RunJavascriptMeta = {
code: string;
import { parseToolArgs } from './_shared';
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
-import { type AgenticSection, getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
+import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
export type WriteFileMeta = {
fileName: string;
agenticResolvePermission
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
+ import type { AgenticSection } from '$lib/types';
import type {
ChatMessageAgenticTimings,
ChatMessageAgenticTurnStats,
DatabaseMessage
} from '$lib/types';
- import { type AgenticSection, deriveAgenticSections } from '$lib/utils';
+ import { deriveAgenticSections } from '$lib/utils';
interface Props {
message: DatabaseMessage;
import { AgenticSectionType } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
- import type { AgenticSection } from '$lib/utils';
+ import type { AgenticSection } from '$lib/types';
interface Props {
section: AgenticSection;
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
- * - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
+ * - Composes ChatFormTextarea (or ChatFormContentEditable for messages with
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* source string. ChatForm swaps it in once a mention link lands in the
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
*/
-export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
+export { default as ChatFormContentEditable } from './ChatForm/ChatFormContentEditable.svelte';
/**
* Plain auto-resizing textarea with IME composition support. Default input
FILE = 'file',
DIRECTORY = 'directory'
}
+
+/**
+ * Kinds of tokens the chat-form contenteditable produces.
+ */
+export enum ContentEditableTokenKind {
+ TEXT = 'text',
+ BADGE = 'badge',
+ INLINE_CODE = 'inlineCode',
+ CODE_BLOCK = 'codeBlock'
+}
PdfViewMode,
ReasoningFormat,
ChatFormCommandAction,
- FileMentionEntryType
+ FileMentionEntryType,
+ ContentEditableTokenKind
} from './chat.enums';
export { SessionRecordType } from './conversation-import.enums';
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';
-export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui.enums';
+export {
+ ColorLevel,
+ ColorMode,
+ HtmlInputType,
+ McpPromptVariant,
+ TooltipSide,
+ UrlProtocol
+} from './ui.enums';
export { KeyboardKey } from './keyboard.enums';
export enum HtmlInputType {
FILE = 'file'
}
+
+/**
+ * Alert level that drives the context gauge dial color.
+ */
+export enum ColorLevel {
+ OK = 'ok',
+ WARNING = 'warning',
+ CRITICAL = 'critical',
+ NEUTRAL = 'neutral'
+}
*/
import { useProcessingState } from './use-processing-state.svelte';
-import {
- type ColorLevel,
- colorLevelFromPercent
-} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
+import { colorLevelFromPercent } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
import { STATS_UNITS } from '$lib/constants';
-import { MessageRole } from '$lib/enums';
+import { ColorLevel, MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import {
ApiChatMessageData
} from './api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
-import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from './database';
+import type {
+ DatabaseMessage,
+ DatabaseMessageExtra,
+ DatabaseMessageExtraAudioFile,
+ DatabaseMessageExtraImageFile
+} from './database';
import type { MessageRole } from '$lib/enums';
-import { ToolCallType } from '$lib/enums';
+import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums';
/**
* Agentic orchestration configuration.
content: string;
extras?: DatabaseMessageExtra[];
}
+
+/**
+ * Represents a parsed section of agentic content for display
+ */
+export interface AgenticSection {
+ type: AgenticSectionType;
+ content: string;
+ toolName?: string;
+ toolArgs?: string;
+ toolResult?: string;
+ toolResultExtras?: DatabaseMessageExtra[];
+ /** Working directory the tool call ran with (from the tool result
+ * message), shown by the exec_shell_command renderer. */
+ toolCwd?: string;
+ /** ID of the model-side tool call (matches tool_calls[i].id). Lets
+ * downstream consumers correlate a section with the agentic loop's
+ * currently-executing tool, e.g. to drive live-streaming UI state
+ * by matching against agenticStore.executingToolCallId. */
+ toolCallId?: string;
+ wasInterrupted?: boolean;
+}
+
+/**
+ * Represents a tool result line that may reference an image attachment
+ */
+export type ToolResultLine = {
+ text: string;
+ media?: DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile;
+};
+
+/**
+ * Classification of how a Continue click on an assistant message should resume
+ * generation. The caller dispatches the resume path based on this value.
+ *
+ * append_text -> the target is a plain text turn, resume with
+ * continue_final_message and rehydrate the persisted
+ * tool_calls and attachments through the regular DB to API
+ * message converter.
+ * rerun_turn -> the target carries tool_calls that were never resolved by
+ * tool result messages. The agentic stream was cut mid turn,
+ * so we drop the target and rerun the loop from the previous
+ * history. truncateAfter is the last kept index, inclusive.
+ * next_turn -> the target's tool_calls were already resolved by trailing
+ * tool results. Hand the history up to and including the
+ * last consecutive tool result back to the agentic loop so it
+ * starts the next turn naturally. truncateAfter points at
+ * that last tool result.
+ */
+export type ContinueIntent =
+ | { kind: ContinueIntentKind.APPEND_TEXT }
+ | { kind: ContinueIntentKind.RERUN_TURN; truncateAfter: number }
+ | { kind: ContinueIntentKind.NEXT_TURN; truncateAfter: number };
--- /dev/null
+import { ContentEditableTokenKind } from '$lib/enums';
+
+/**
+ * A single token produced by the chat-form contenteditable tokenizer:
+ * plain text, a file/folder mention badge, or an inline/fenced code span.
+ */
+export type ContentEditableToken =
+ | { kind: ContentEditableTokenKind.TEXT; text: string }
+ | { kind: ContentEditableTokenKind.BADGE; name: string; path: string }
+ | { kind: ContentEditableTokenKind.INLINE_CODE; text: string }
+ | { kind: ContentEditableTokenKind.CODE_BLOCK; text: string };
--- /dev/null
+import type { GlobSearchType } from '$lib/enums';
+
+/**
+ * A single directory entry returned by the server's `file_glob_search`
+ * tool.
+ */
+export interface GlobEntry {
+ path: string;
+ type: string;
+}
+
+/**
+ * Query arguments for a `file_glob_search` run.
+ */
+export interface GlobSearchArgs {
+ path: string;
+ include: string;
+ maxDepth: number;
+ rankQuery: string;
+ /** Last segment of a path-navigation query (`~/dir/sub`), undefined for
+ * a plain home-relative glob. Lets callers act on the exact targeted
+ * segment (e.g. the WD picker "entering" a directory). */
+ last?: string;
+}
+
+/**
+ * Ranked result of a glob search against a base path.
+ */
+export interface GlobSearchResult {
+ base: string;
+ entries: GlobEntry[];
+ error?: string;
+}
+
+/**
+ * A glob entry resolved to an absolute path with its display name.
+ */
+export interface GlobEntryResult {
+ path: string;
+ name: string;
+ type: string;
+}
+
+/**
+ * Options controlling how a search descends into a matched directory.
+ */
+export interface GlobSearchChildOptions {
+ type?: GlobSearchType;
+ /** Descend only on a trailing path separator (mention picker); off for
+ * the WD picker, which descends on any exact match. */
+ descendOnTrailingSeparator?: boolean;
+ childMaxDepth?: number;
+}
+
+/**
+ * Result of a glob search that may also list a matched directory's
+ * children.
+ */
+export interface GlobSearchChildResult {
+ base: string;
+ args: GlobSearchArgs;
+ /** Outer ranked entries plus the walked directory's children (absolute). */
+ entries: GlobEntryResult[];
+ /** Absolute path of the directory whose children were appended. */
+ exactDir?: string;
+ error?: string;
+}
MCPServerResources
} from './mcp';
+// Search result types
+export type { SearchResult } from './search';
+
+// Glob search types (working-directory / mention pickers)
+export type {
+ GlobEntry,
+ GlobSearchArgs,
+ GlobSearchResult,
+ GlobEntryResult,
+ GlobSearchChildOptions,
+ GlobSearchChildResult
+} from './glob';
+
+// Contenteditable token types (chat form)
+export type { ContentEditableToken } from './contenteditable';
+
// Agentic types
export type {
AgenticConfig,
AgenticFlowOptions,
AgenticFlowParams,
AgenticFlowResult,
- SteeringMessage
+ SteeringMessage,
+ AgenticSection,
+ ToolResultLine,
+ ContinueIntent
} from './agentic';
// Navigation types
--- /dev/null
+/**
+ * A single parsed entry from a web-search MCP tool result.
+ */
+export type SearchResult = {
+ title: string;
+ url: string;
+ published?: string;
+ author?: string;
+ highlights?: string;
+};
MessageRole,
ToolResultKind
} from '$lib/enums';
+import type { AgenticSection, ContinueIntent, ToolResultLine } from '$lib/types/agentic';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
-/**
- * Represents a parsed section of agentic content for display
- */
-export interface AgenticSection {
- type: AgenticSectionType;
- content: string;
- toolName?: string;
- toolArgs?: string;
- toolResult?: string;
- toolResultExtras?: DatabaseMessageExtra[];
- /** Working directory the tool call ran with (from the tool result
- * message), shown by the exec_shell_command renderer. */
- toolCwd?: string;
- /** ID of the model-side tool call (matches tool_calls[i].id). Lets
- * downstream consumers correlate a section with the agentic loop's
- * currently-executing tool, e.g. to drive live-streaming UI state
- * by matching against agenticStore.executingToolCallId. */
- toolCallId?: string;
- wasInterrupted?: boolean;
-}
-
-/**
- * Represents a tool result line that may reference a media attachment (image or audio)
- */
-export type ToolResultLine = {
- text: string;
- media?: DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile;
-};
-
/**
* Derives display sections from a single assistant message and its direct tool results.
*
return toolMessages.length > 0;
}
-/**
- * Classification of how a Continue click on an assistant message should resume
- * generation. The caller dispatches the resume path based on this value.
- *
- * append_text -> the target is a plain text turn, resume with
- * continue_final_message and rehydrate the persisted
- * tool_calls and attachments through the regular DB to API
- * message converter.
- * rerun_turn -> the target carries tool_calls that were never resolved by
- * tool result messages. The agentic stream was cut mid turn,
- * so we drop the target and rerun the loop from the previous
- * history. truncateAfter is the last kept index, inclusive.
- * next_turn -> the target's tool_calls were already resolved by trailing
- * tool results. Hand the history up to and including the
- * last consecutive tool result back to the agentic loop so it
- * starts the next turn naturally. truncateAfter points at
- * that last tool result.
- */
-export type ContinueIntent =
- | { kind: ContinueIntentKind.APPEND_TEXT }
- | { kind: ContinueIntentKind.RERUN_TURN; truncateAfter: number }
- | { kind: ContinueIntentKind.NEXT_TURN; truncateAfter: number };
-
/**
* Decide how a Continue click on messages[idx] should resume generation.
* Pure function over the persisted history snapshot.
MENTION_BADGE_SVG_ATTRIBUTES,
SETTINGS_KEYS
} from '$lib/constants';
+import { ContentEditableTokenKind } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
-
-export type ContentToken =
- | { kind: 'text'; text: string }
- | { kind: 'badge'; name: string; path: string }
- | { kind: 'inlineCode'; text: string }
- | { kind: 'codeBlock'; text: string };
+import type { ContentEditableToken } from '$lib/types/contenteditable';
// Block wrappers browsers insert for newlines; each folds back into a
// single `\n` during serialization.
* interleave in the remaining gaps. Any whitespace after a badge
* stays in a plain text token so the round trip is byte-exact.
*/
-export function tokenizeContent(input: string): ContentToken[] {
- const tokens: ContentToken[] = [];
+export function tokenizeContent(input: string): ContentEditableToken[] {
+ const tokens: ContentEditableToken[] = [];
let cursor = 0;
tokens.push(
match[1] !== undefined
- ? { kind: 'codeBlock', text: match[1] }
- : { kind: 'inlineCode', text: match[2] }
+ ? { kind: ContentEditableTokenKind.CODE_BLOCK, text: match[1] }
+ : { kind: ContentEditableTokenKind.INLINE_CODE, text: match[2] }
);
cursor = start + match[0].length;
}
/**
* Tokenize a code-free segment into text and badge tokens.
*/
-function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
+function pushTextAndBadgeTokens(input: string, tokens: ContentEditableToken[]) {
let cursor = 0;
MENTION_BADGE_RE.lastIndex = 0;
const start = match.index;
if (start > cursor) {
- tokens.push({ kind: 'text', text: input.slice(cursor, start) });
+ tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor, start) });
}
- tokens.push({ kind: 'badge', name, path });
+ tokens.push({ kind: ContentEditableTokenKind.BADGE, name, path });
cursor = start + whole.length;
}
if (cursor < input.length) {
- tokens.push({ kind: 'text', text: input.slice(cursor) });
+ tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor) });
}
}
* A mismatch means token boundaries shifted (a code span was just
* completed or broken) and the DOM needs a rebuild to restyle.
*/
-export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
- const expected = tokens.filter((token) => token.kind !== 'text');
+export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken[]): boolean {
+ const expected = tokens.filter((token) => token.kind !== ContentEditableTokenKind.TEXT);
let index = 0;
if (!token) return false;
if (isBadge) {
- if (token.kind !== 'badge') return false;
+ if (token.kind !== ContentEditableTokenKind.BADGE) return false;
if (token.name !== (el.dataset.mentionName ?? '')) return false;
continue;
}
- const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
+ const codeKind: ContentEditableTokenKind =
+ el.dataset.codeToken === 'block'
+ ? ContentEditableTokenKind.CODE_BLOCK
+ : ContentEditableTokenKind.INLINE_CODE;
if (token.kind !== codeKind) return false;
if (
- (token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
- token.text !== (el.textContent ?? '')
+ token.kind === ContentEditableTokenKind.INLINE_CODE ||
+ token.kind === ContentEditableTokenKind.CODE_BLOCK
) {
- return false;
+ if (token.text !== (el.textContent ?? '')) return false;
}
}
* string + inline SVG are shared with the rehype plugin via
* `$lib/constants`.
*/
-export function buildFragment(tokens: ContentToken[]): DocumentFragment {
+export function buildFragment(tokens: ContentEditableToken[]): DocumentFragment {
const fragment = document.createDocumentFragment();
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
- if (token.kind === 'text') {
+ if (token.kind === ContentEditableTokenKind.TEXT) {
let text = token.text;
// The separator \n at a fenced-block boundary is synthesized
// at serialization time; keeping it in the DOM would render a
// phantom empty line next to the block.
- if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
+ if (
+ tokens[index - 1]?.kind === ContentEditableTokenKind.CODE_BLOCK &&
+ text.startsWith('\n')
+ ) {
text = text.slice(1);
}
- if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
+ if (tokens[index + 1]?.kind === ContentEditableTokenKind.CODE_BLOCK && text.endsWith('\n')) {
text = text.slice(0, -1);
}
continue;
}
- if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
+ if (
+ token.kind === ContentEditableTokenKind.INLINE_CODE ||
+ token.kind === ContentEditableTokenKind.CODE_BLOCK
+ ) {
const code = document.createElement('code');
- code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
+ code.dataset.codeToken =
+ token.kind === ContentEditableTokenKind.CODE_BLOCK ? 'block' : 'inline';
code.textContent = token.text;
fragment.appendChild(code);
for (const token of tokenizeContent(source)) {
const len =
- token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
+ token.kind === ContentEditableTokenKind.BADGE
+ ? badgeSourceLength(token.name, token.path)
+ : token.text.length;
- if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
+ if (token.kind === ContentEditableTokenKind.BADGE)
+ badgeSpans.push([masked.length, masked.length + len]);
- masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
+ masked += token.kind === ContentEditableTokenKind.BADGE ? 'a'.repeat(len) : token.text;
}
if (badgeSpans.length === 0) return null;
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
const [first] = tokenizeContent(source);
- if (!first || first.kind !== 'badge') return null;
+ if (!first || first.kind !== ContentEditableTokenKind.BADGE) return null;
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
}
*/
import { lastPathSegment } from './path-display';
-import {
- buildGlobSearchArgs,
- type GlobEntry,
- type GlobSearchArgs,
- joinPath,
- rankEntries
-} from './working-directory';
+import { buildGlobSearchArgs, joinPath, rankEntries } from './working-directory';
import { GLOB, PATH_SEPARATOR, SEARCH } from '$lib/constants';
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
+import type {
+ GlobEntry,
+ GlobEntryResult,
+ GlobSearchArgs,
+ GlobSearchChildOptions,
+ GlobSearchChildResult,
+ GlobSearchResult
+} from '$lib/types/glob';
const SEARCH_CACHE_TTL_MS = 2000;
const searchCache = new Map<string, CacheEntry>();
-export interface GlobSearchResult {
- base: string;
- entries: GlobEntry[];
- error?: string;
-}
-
export async function runGlobSearch(
args: GlobSearchArgs,
type: GlobSearchType,
return { base, entries };
}
-export interface GlobEntryResult {
- path: string;
- name: string;
- type: string;
-}
-
-export interface GlobSearchChildOptions {
- type?: GlobSearchType;
- /** Descend only on a trailing path separator (mention picker); off for
- * the WD picker, which descends on any exact match. */
- descendOnTrailingSeparator?: boolean;
- childMaxDepth?: number;
-}
-
-export interface GlobSearchChildResult {
- base: string;
- args: GlobSearchArgs;
- /** Outer ranked entries plus the walked directory's children (absolute). */
- entries: GlobEntryResult[];
- /** Absolute path of the directory whose children were appended. */
- exactDir?: string;
- error?: string;
-}
-
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
return { name: lastPathSegment(e.path), path: joinPath(base, e.path), type: e.type };
}
rankEntries,
joinPath,
highlightMatch,
- type GlobEntry,
- type GlobSearchArgs,
type PathQuery
} from './working-directory';
// Shared `file_glob_search` runner with a short-lived result cache
-export {
- runGlobSearch,
- runGlobSearchWithChildren,
- type GlobEntryResult,
- type GlobSearchResult
-} from './glob-search';
+export { runGlobSearch, runGlobSearchWithChildren } from './glob-search';
// Mention-token detection (for the `@`-triggered file/folder mention picker)
export {
rangeToTextOffset,
textOffsetToRange,
badgeAwareWordJump,
- leadingBadgeEdgeOffset,
- type ContentToken
+ leadingBadgeEdgeOffset
} from './contenteditable-tokenizer';
// Source-space undo/redo history for the chat-form contenteditable
parseToolResultWithMedia,
splitSearchSummaryList,
hasAgenticContent,
- classifyToolResult,
- type AgenticSection,
- type ToolResultLine
+ classifyToolResult
} from './agentic';
// Line-level unified diff for tool result rendering (`edit_file` block)
extractSearchResults,
extractSearchQuery,
faviconForUrl,
- isWebSearchToolName,
- type SearchResult
+ isWebSearchToolName
} from './search-results';
// Cache utilities
// Re-exported through $lib/utils so renderer components can read the
// label without depending on $lib/constants directly.
export { getBuiltinToolUi } from './built-in-tools';
-export type { BuiltinToolUiEntry } from '$lib/types';
// Chat command picker
+import type { SearchResult } from '$lib/types/search';
+
/**
* Parsers for MCP web-search tool responses shaped like:
*
* servers without hardcoding tool names.
*/
-export type SearchResult = {
- title: string;
- url: string;
- published?: string;
- author?: string;
- highlights?: string;
-};
-
const SEPARATOR_LINE_RE = /^\s*---\s*$/;
const URL_SCHEME_RE = /^https?:\/\//i;
// Match either Unix or Windows line endings so chunking/parsing handles
SEARCH,
TRAILING_SLASHES_REGEX
} from '$lib/constants';
-
-export interface GlobEntry {
- path: string;
- type: string;
-}
+import type { GlobEntry, GlobSearchArgs } from '$lib/types/glob';
export interface PathQuery {
parent: string;
return out + GLOB.WILDCARD;
}
-export interface GlobSearchArgs {
- path: string;
- include: string;
- maxDepth: number;
- rankQuery: string;
- /** Last segment of a path-navigation query (`~/dir/sub`), undefined for
- * a plain home-relative glob. Lets callers act on the exact targeted
- * segment (e.g. the WD picker "entering" a directory). */
- last?: string;
-}
-
export function buildGlobSearchArgs(
query: string,
scopePath: string,
// serialization must fold those back into `\n` so the emitted value never
// diverges from what is on screen.
-import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
+import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
import { tick } from 'svelte';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
selection.addRange(range);
}
-describe('ChatFormContenteditable browser newline shapes', () => {
+describe('ChatFormContentEditable browser newline shapes', () => {
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+ const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
await tick();
});
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+ const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
await tick();
});
it('serializes a <br> as a newline', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'here' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'here' });
await tick();
});
it('ignores a trailing <br> (browser caret placeholder)', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('serializes one newline per empty-line <div><br></div>', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('maps the caret across block boundaries in both directions', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc\ndef' });
await tick();
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
// keyboard trap), matching the plain textarea.
-import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
+import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
import { tick } from 'svelte';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
return event;
}
-describe('ChatFormContenteditable undo/redo', () => {
+describe('ChatFormContentEditable undo/redo', () => {
it('undoes and redoes an edit across a badge-containing buffer', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+ const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
await tick();
});
it('redoes with Ctrl+Y as well', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+ const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
await tick();
});
it('coalesces a typing burst into one undo step', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('keeps a newline as its own undo step', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('is a no-op when there is nothing to undo', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
it('abandons the redo branch after a fresh edit', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+ const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
await tick();
});
});
-describe('ChatFormContenteditable Tab key', () => {
+describe('ChatFormContentEditable Tab key', () => {
it('does not trap Tab (focus can leave the editable)', async () => {
- const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+ const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
await tick();
// contributes its full `[name](file://...)` link) and pasting such
// markdown re-renders the badges.
-import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
+import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
import { tick } from 'svelte';
import { describe, expect, it, vi } from 'vitest';
return { data, event };
}
-describe('ChatFormContenteditable clipboard', () => {
+describe('ChatFormContentEditable clipboard', () => {
it('copy exposes the markdown source of the selection', async () => {
- const { container } = render(ChatFormContenteditable, { value: SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: SOURCE });
await tick();
});
it('cut exposes the markdown source and removes the slice', async () => {
- const { container } = render(ChatFormContenteditable, { value: SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: SOURCE });
await tick();
});
it('paste of markdown mention links re-renders badges', async () => {
- const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+ const { container } = render(ChatFormContentEditable, { value: 'hello ' });
await tick();
});
it('paste without mention links keeps the DOM untouched', async () => {
- const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+ const { container } = render(ChatFormContentEditable, { value: 'hello ' });
await tick();
});
});
-describe('ChatFormContenteditable code spans', () => {
+describe('ChatFormContentEditable code spans', () => {
it('renders inline code from the initial value', async () => {
- const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
+ const { container } = render(ChatFormContentEditable, { value: 'run `npm test` now' });
await tick();
it('renders a fenced code block with a language', async () => {
const source = 'before\n```js\nconst a = 1;\n```\nafter';
- const { container } = render(ChatFormContenteditable, { value: source });
+ const { container } = render(ChatFormContentEditable, { value: source });
await tick();
it('copy exposes the markdown source of a selection spanning code', async () => {
const source = 'run `npm test` now';
- const { container } = render(ChatFormContenteditable, { value: source });
+ const { container } = render(ChatFormContentEditable, { value: source });
await tick();
});
it('paste of a code span renders the styled element', async () => {
- const { container } = render(ChatFormContenteditable, { value: 'run ' });
+ const { container } = render(ChatFormContentEditable, { value: 'run ' });
await tick();
it('highlights a fenced block content and stays byte-exact', async () => {
const source = '```js\nconst a = 1;\n```';
- const { container } = render(ChatFormContenteditable, { value: source });
+ const { container } = render(ChatFormContentEditable, { value: source });
await tick();
});
it('does not highlight inline code', async () => {
- const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
+ const { container } = render(ChatFormContentEditable, { value: 'run `const` now' });
await tick();
});
});
-describe('ChatFormContenteditable code block escape hatches', () => {
+describe('ChatFormContentEditable code block escape hatches', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
}
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('escapes a trailing code block with ArrowDown and types after it', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('escapes a leading code block with ArrowUp and types before it', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('escapes a leading code block with ArrowLeft from its first character', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('removes the transient leading hatch when the caret moves back into the block', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('extends the selection out of the block with Shift+ArrowDown', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('line-separates text typed right after the closing fence', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('does not double the newline when Shift+Enter already added one', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('moves a caret stuck before the inserted newline onto the new line', async () => {
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
});
it('appends the artificial trailing newline when the browser did not add one', async () => {
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
});
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
});
it('lets Backspace at the text start move into the block without a source fight', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('lets forward Delete eat the text after a block normally', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
it('renders text after a block without a phantom empty line', async () => {
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
value: BLOCK_SOURCE + '\nhello'
});
});
it('keeps an intentional blank line after a block out of the separator', async () => {
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
value: BLOCK_SOURCE + '\n\nhello'
});
});
it('re-highlights while typing inside a block and keeps the caret', async () => {
- const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+ const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
await tick();
});
});
-describe('ChatFormContenteditable Enter in code blocks', () => {
+describe('ChatFormContentEditable Enter in code blocks', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
it('adds a line instead of submitting on plain Enter inside a block', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: BLOCK_SOURCE
});
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: '```js\nconst a = 1;'
});
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: BLOCK_SOURCE + '\nafter'
});
it('forwards plain Enter on the trailing hatch line after a block', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: BLOCK_SOURCE
});
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: BLOCK_SOURCE
});
it('forwards Enter inside an inline code span', async () => {
const onKeydown = vi.fn();
- const { container } = render(ChatFormContenteditable, {
+ const { container } = render(ChatFormContentEditable, {
onKeydown,
value: 'run `npm test` now'
});
--- /dev/null
+<script lang="ts">
+ import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
+ import { untrack } from 'svelte';
+
+ interface Props {
+ value?: string;
+ }
+
+ let { value: initial = '' }: Props = $props();
+
+ let value = $state(untrack(() => initial));
+ let inputRef: ChatFormContentEditable | undefined = $state(undefined);
+
+ export function getValue() {
+ return value;
+ }
+
+ export function getCaretOffset() {
+ return inputRef?.getCaretOffset();
+ }
+
+ export function setCaretOffset(offset: number) {
+ inputRef?.setCaretOffset(offset);
+ }
+</script>
+
+<ChatFormContentEditable bind:this={inputRef} bind:value />
+++ /dev/null
-<script lang="ts">
- import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
- import { untrack } from 'svelte';
-
- interface Props {
- value?: string;
- }
-
- let { value: initial = '' }: Props = $props();
-
- let value = $state(untrack(() => initial));
- let inputRef: ChatFormContenteditable | undefined = $state(undefined);
-
- export function getValue() {
- return value;
- }
-
- export function getCaretOffset() {
- return inputRef?.getCaretOffset();
- }
-
- export function setCaretOffset(offset: number) {
- inputRef?.setCaretOffset(offset);
- }
-</script>
-
-<ChatFormContenteditable bind:this={inputRef} bind:value />
import { REASONING_TAGS } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
-import { type AgenticSection, buildAssistantRawOutput } from '$lib/utils/agentic';
+import type { AgenticSection } from '$lib/types/agentic';
+import { buildAssistantRawOutput } from '$lib/utils/agentic';
import { describe, expect, it } from 'vitest';
function makeSection(
type WriteFileMeta
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
-import type { AgenticSection } from '$lib/utils';
+import type { AgenticSection } from '$lib/types';
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
import { describe, expect, it } from 'vitest';