]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
refactor: Clean up UI types (#26909)
authorAleksander Grygier <redacted>
Thu, 13 Aug 2026 06:07:34 +0000 (08:07 +0200)
committerGitHub <redacted>
Thu, 13 Aug 2026 06:07:34 +0000 (08:07 +0200)
55 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadMedia.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-media.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/enums/chat.enums.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/ui.enums.ts
tools/ui/src/lib/hooks/use-context-gauge.svelte.ts
tools/ui/src/lib/types/agentic.d.ts
tools/ui/src/lib/types/contenteditable.d.ts [new file with mode: 0644]
tools/ui/src/lib/types/glob.d.ts [new file with mode: 0644]
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/search.d.ts [new file with mode: 0644]
tools/ui/src/lib/utils/agentic.ts
tools/ui/src/lib/utils/contenteditable-tokenizer.ts
tools/ui/src/lib/utils/glob-search.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/search-results.ts
tools/ui/src/lib/utils/working-directory.ts
tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts
tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts
tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts
tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte [new file with mode: 0644]
tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte [deleted file]
tools/ui/tests/unit/assistant-raw-output.test.ts
tools/ui/tests/unit/tool-calls.test.ts

index bc1befd63320a8fa58491fbd46a15d3256db01cb..9921d8d88d428c3bb48fe658eb94601ea5b2ef80 100644 (file)
@@ -3,7 +3,7 @@
        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
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte
new file mode 100644 (file)
index 0000000..2062076
--- /dev/null
@@ -0,0 +1,849 @@
+<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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte
deleted file mode 100644 (file)
index ec916b3..0000000
+++ /dev/null
@@ -1,848 +0,0 @@
-<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>
index 8f3ec435212a98c2c191f3b4c92a65f0df183f17..67d705ae45067567cc66c6556b6454403de1c9c2 100644 (file)
@@ -1,6 +1,6 @@
 <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;
index 42aab668e693e181e96c1fcf02b8173f31986391..e0a7f74780ecd44f807cd611c3bc483ba3dea464 100644 (file)
@@ -1,25 +1,25 @@
-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';
@@ -28,11 +28,11 @@ export function colorLevelTextClass(level: ColorLevel): string {
 
 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';
index 86a9fb81cd2f5f07511868a078a085c9b55e734c..6a2be8cffdf1751caec5c05866f049a9784e39b0 100644 (file)
@@ -11,8 +11,8 @@
        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
index 27bc40b24a3baa7061d7092d677139e96e439879..639ddb52475daaed8ea94f007106247f90a28f67 100644 (file)
        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
index 7a00f1a8eaa658f132c44421416dbc6078812124..db09bb7235d381da35a9b9c8ee50c438f8a04f8a 100644 (file)
        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;
index 79120cb175a15d6c05c398b18e382fd6a29dd8db..c45ec5584356582aa3ff92938eb29734be4c9e56 100644 (file)
@@ -8,14 +8,12 @@
        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';
 
index 5ee9d4936e8047a071c98f4c16441948eecc1b70..6b95a92fb2e78b0fa5e045ad09483c78c924ec7d 100644 (file)
@@ -4,7 +4,8 @@
        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;
index ef2bb74336ee8a7bbfdaf4658251c0c3ffc5e7ce..caf6d832b1955dc5a71d83fcd741de8df32abae2 100644 (file)
        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}
index 37f7350fb7079177bd9279037b464dffc6921249..66eb3a2ad6226902cd4de17fed84de318917ded3 100644 (file)
@@ -3,7 +3,8 @@
        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;
index 4c525daa7b07921e25b2cd5ec31f72e1978359e9..23de7cbd6caae5a40f66a12d4d7878256b684e08 100644 (file)
@@ -1,7 +1,7 @@
 <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;
index 7a975dc1b52846d7fb9fe04eabe4dfeffbcdf5bb..9e6ef10919db6a72d06455efba81de2c0387d022 100644 (file)
@@ -2,7 +2,8 @@
        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;
index 86db568dea79565c89369ff59b4eacbb331ef791..c650318adb06188be01f479d7621a20396b8c73d 100644 (file)
@@ -3,7 +3,8 @@
        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;
index a655d86a5f2fa44209478376dcd6d1aa54070f6b..ad29e31d8fa12cab2c73f27b04b069cd6e279c11 100644 (file)
@@ -3,7 +3,7 @@
        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;
index 426fb454407b7ff3a3b047e814c42109fff0b1e2..c6b2615c027b866dfac9589e0016d4431f8d1de3 100644 (file)
@@ -4,7 +4,7 @@
        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 {
index 38724e8d7717381e4dd47b41d898fca88eda44d9..a566f9be4e5c57b70b31d74e95a6bfd23d85b0cd 100644 (file)
@@ -5,7 +5,8 @@
        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;
index 142ca843741fce27f52b6da7c77953ec9d6a2553..67a66d7da8a1a5d91cc6181fbffd716ec90631f9 100644 (file)
@@ -5,13 +5,12 @@
        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 {
index 742685fcc9c70c28cf50c0dad37e911476e84a2a..0f3917f6ab31ea28dfd604e53d9c378f527623c8 100644 (file)
@@ -5,7 +5,8 @@
        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;
index c43495b6c7d32a8cbb6f230292ec165da930d69d..ea08f01357975e1f2cd14b02356df2268607471b 100644 (file)
@@ -14,7 +14,8 @@
        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 };
index d8ca812b645482679893145e6b412d8e20b0b06f..073e03de27b17dcacaf3d51d25e680cb99a84399 100644 (file)
@@ -5,7 +5,7 @@
 // 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';
 
 /**
index 0c1fb9e432d9274bd231f30fd23c150ae1a85859..dadb33a49952470237d669232a73987e4602f100 100644 (file)
@@ -6,7 +6,8 @@
 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;
index aa7cd704933f070dab83eca628c45695b81fb035..496fcdde66978cac7bc608f66ae2063c9f14271b 100644 (file)
@@ -7,7 +7,7 @@
 
 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;
index bbd50ce89172bbd080981bafa6e0e25fdb1e66f1..0acd53d7758920785ed854c7bed2b698dc780214 100644 (file)
@@ -6,7 +6,8 @@
 
 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;
index 69c95210f32cac2a8385eb14a00914470a3a90a5..c25d76b1a613d67a8bcb992729ee942b8bf77d95 100644 (file)
@@ -7,7 +7,8 @@
 
 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;
index 0b275f9732e4da83540cecf48c7dd11d0db423cc..9ee748ed7812c5c25392f0099dee81c1238efcda 100644 (file)
@@ -6,7 +6,8 @@
 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;
index fbfdb33626adefc36ac742116971d42a4c0014f2..e973a2f99a002dc300c3f25814fc3e68ec003203 100644 (file)
@@ -5,7 +5,7 @@ import {
        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;
index a74153d4dcd35656ed8778e813e50d67e8962208..a524478ab4661768ed618f85a4dde3cae30db5bd 100644 (file)
@@ -7,7 +7,7 @@
 
 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;
index df91d5cae038dfb52298b140669b438d3a2169ac..53ba38e1273ca89afceaf1ae4512e76bcb81b9fb 100644 (file)
@@ -6,7 +6,8 @@
 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;
index 2b4a22dd449746da97cafe8aa86aa6103ecc62cf..7fa29a899521db09d13a20217854a26e17c03ed7 100644 (file)
                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;
index ebcc7279a9e2d78f22e934edb3e859857c69197a..fbd1567615b9cb7765186f6aef3eb614c5de9b1d 100644 (file)
@@ -5,7 +5,7 @@
        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;
index 04b1d915f68a36829309587c729a6bb9f79def3a..5c8848264865b52d084b95ae274ebe451e00290c 100644 (file)
@@ -120,7 +120,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * 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
@@ -272,7 +272,7 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
  * 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
index 198ff4dd7cbe0f9adfab7a9a2221128d7745601e..ac39695361926d9951f8dfb8ed7e3f1416cbf7f7 100644 (file)
@@ -89,3 +89,13 @@ export enum FileMentionEntryType {
        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'
+}
index 217ecc2fe197547a556f9ed99e7894452283426a..226cc134d038829c0676e4441f238eaaa481d2bd 100644 (file)
@@ -27,7 +27,8 @@ export {
        PdfViewMode,
        ReasoningFormat,
        ChatFormCommandAction,
-       FileMentionEntryType
+       FileMentionEntryType,
+       ContentEditableTokenKind
 } from './chat.enums';
 
 export { SessionRecordType } from './conversation-import.enums';
@@ -71,7 +72,14 @@ export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './serve
 
 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';
 
index 6ba0222e9737145a67b096c0208c4b4a24ec0955..5ed4c1edbc44a7c8d0ab4b9a1b0b2ebe97ded225 100644 (file)
@@ -34,3 +34,13 @@ export enum UrlProtocol {
 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'
+}
index 7ab3f83df609aae41d6850d9c9adb9a578de7eb2..372f314aaeca49ba4c5f688a71495518c6aa635b 100644 (file)
@@ -5,12 +5,9 @@
  */
 
 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 {
index e4ab61035bca88c5668c777c07de8b0faa1d4ae1..cf939371ec4333382ff6cdcca829fc1b09a2ee8c 100644 (file)
@@ -5,9 +5,14 @@ import type {
        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.
@@ -171,3 +176,55 @@ export interface SteeringMessage {
        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 };
diff --git a/tools/ui/src/lib/types/contenteditable.d.ts b/tools/ui/src/lib/types/contenteditable.d.ts
new file mode 100644 (file)
index 0000000..8c610bf
--- /dev/null
@@ -0,0 +1,11 @@
+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 };
diff --git a/tools/ui/src/lib/types/glob.d.ts b/tools/ui/src/lib/types/glob.d.ts
new file mode 100644 (file)
index 0000000..d863bce
--- /dev/null
@@ -0,0 +1,67 @@
+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;
+}
index a7e755040cfabd03a10b15ca4bd8e7f85dc0f5db..917c9516b578014eb54734988004b8aab43af55b 100644 (file)
@@ -161,6 +161,22 @@ export type {
        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,
@@ -174,7 +190,10 @@ export type {
        AgenticFlowOptions,
        AgenticFlowParams,
        AgenticFlowResult,
-       SteeringMessage
+       SteeringMessage,
+       AgenticSection,
+       ToolResultLine,
+       ContinueIntent
 } from './agentic';
 
 // Navigation types
diff --git a/tools/ui/src/lib/types/search.d.ts b/tools/ui/src/lib/types/search.d.ts
new file mode 100644 (file)
index 0000000..1ce091e
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * A single parsed entry from a web-search MCP tool result.
+ */
+export type SearchResult = {
+       title: string;
+       url: string;
+       published?: string;
+       author?: string;
+       highlights?: string;
+};
index 2be4dc71476794e70bc4d31796896089cc5af6b9..cd150c5efbff1b23bb52ab1a64f315c6e5bee9dc 100644 (file)
@@ -13,6 +13,7 @@ import {
        MessageRole,
        ToolResultKind
 } from '$lib/enums';
+import type { AgenticSection, ContinueIntent, ToolResultLine } from '$lib/types/agentic';
 import type { ApiChatCompletionToolCall } from '$lib/types/api';
 import type {
        DatabaseMessage,
@@ -20,35 +21,6 @@ import type {
        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.
  *
@@ -485,29 +457,6 @@ export function hasAgenticContent(
        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.
index ea6939b23a0aef4d0a1456c32f75cbcbe1aaf96f..db271398c4830e2568c746f0496c03d69d1459a4 100644 (file)
@@ -35,14 +35,10 @@ import {
        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.
@@ -112,8 +108,8 @@ export function isOffsetInCodeBlock(source: string, offset: number): boolean {
  * 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;
 
@@ -130,8 +126,8 @@ export function tokenizeContent(input: string): ContentToken[] {
 
                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;
        }
@@ -146,7 +142,7 @@ export function tokenizeContent(input: string): ContentToken[] {
 /**
  * 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;
@@ -158,15 +154,15 @@ function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
                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) });
        }
 }
 
@@ -289,8 +285,8 @@ export function serializeContent(root: HTMLElement): string {
  * 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;
 
@@ -313,7 +309,7 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boo
                        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;
 
@@ -322,15 +318,18 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boo
                                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;
                        }
                }
 
@@ -522,23 +521,26 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
  * 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);
                        }
 
@@ -549,10 +551,14 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
                        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);
 
@@ -728,11 +734,14 @@ export function badgeAwareWordJump(
 
        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;
@@ -795,7 +804,7 @@ export function badgeAwareWordJump(
 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;
 }
index ded32793b6bf49fcaca2bb899be0e3c9da2e093f..7580f32867c13f216dd70565a7aae89d3cdde436 100644 (file)
@@ -5,16 +5,18 @@
  */
 
 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;
 
@@ -26,12 +28,6 @@ interface CacheEntry {
 
 const searchCache = new Map<string, CacheEntry>();
 
-export interface GlobSearchResult {
-       base: string;
-       entries: GlobEntry[];
-       error?: string;
-}
-
 export async function runGlobSearch(
        args: GlobSearchArgs,
        type: GlobSearchType,
@@ -66,30 +62,6 @@ export async function runGlobSearch(
        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 };
 }
index eef5be9bfeadea73303150244143730d5b0fc277..35e20aa168f654e2c2d96bee69a2574a136512bc 100644 (file)
@@ -179,18 +179,11 @@ export {
        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 {
@@ -219,8 +212,7 @@ export {
        rangeToTextOffset,
        textOffsetToRange,
        badgeAwareWordJump,
-       leadingBadgeEdgeOffset,
-       type ContentToken
+       leadingBadgeEdgeOffset
 } from './contenteditable-tokenizer';
 
 // Source-space undo/redo history for the chat-form contenteditable
@@ -251,9 +243,7 @@ export {
        parseToolResultWithMedia,
        splitSearchSummaryList,
        hasAgenticContent,
-       classifyToolResult,
-       type AgenticSection,
-       type ToolResultLine
+       classifyToolResult
 } from './agentic';
 
 // Line-level unified diff for tool result rendering (`edit_file` block)
@@ -276,8 +266,7 @@ export {
        extractSearchResults,
        extractSearchQuery,
        faviconForUrl,
-       isWebSearchToolName,
-       type SearchResult
+       isWebSearchToolName
 } from './search-results';
 
 // Cache utilities
@@ -318,7 +307,6 @@ export { tryParseToolResultObject } from './tool-call-meta';
 // 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
 
index af04e7b07a1d9de411cf942c977f3f1157e79151..db825c3ebacad5d1f527aca0898a4ba5b420e62f 100644 (file)
@@ -1,3 +1,5 @@
+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
index 09ee87ecc440a02e201763f7f8fad3478aa4c586..906142d1c1f43528c7a4292de57d871ff51bba6c 100644 (file)
@@ -14,11 +14,7 @@ import {
        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;
@@ -93,17 +89,6 @@ export function buildCaseInsensitiveGlob(query: string): 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,
index 602879b991606e49b53aff753ba874587567783f..0b719c43f8e5eafa0426804f76ea4e55e19cee00 100644 (file)
@@ -3,7 +3,7 @@
 // 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';
@@ -35,9 +35,9 @@ function setCaret(node: Node, offset: number) {
        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();
 
@@ -53,7 +53,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        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();
 
@@ -73,7 +73,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        it('serializes a <br> as a newline', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: 'here' });
+               const screen = render(ChatFormContentEditableHarness, { value: 'here' });
 
                await tick();
 
@@ -88,7 +88,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        it('ignores a trailing <br> (browser caret placeholder)', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
 
                await tick();
 
@@ -102,7 +102,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        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();
 
@@ -121,7 +121,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        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();
 
@@ -138,7 +138,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
        });
 
        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();
 
index 17406f95eaa9a59874f386ddeef4c5fb548eefd6..7ed01fd99b001627073974ed7d3dbd41ba606dd3 100644 (file)
@@ -3,7 +3,7 @@
 // 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';
@@ -31,9 +31,9 @@ function keydown(root: HTMLElement, init: KeyboardEventInit) {
        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();
 
@@ -57,7 +57,7 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 
        it('redoes with Ctrl+Y as well', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
 
                await tick();
 
@@ -75,7 +75,7 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 
        it('coalesces a typing burst into one undo step', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
 
                await tick();
 
@@ -92,7 +92,7 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 
        it('keeps a newline as its own undo step', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
 
                await tick();
 
@@ -113,7 +113,7 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 
        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();
 
@@ -127,7 +127,7 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 
        it('abandons the redo branch after a fresh edit', async () => {
-               const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
 
                await tick();
 
@@ -147,9 +147,9 @@ describe('ChatFormContenteditable undo/redo', () => {
        });
 });
 
-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();
 
index d10b15c827980f6a1fb30170dca139d15e014050..0767fa715f23a4e070f8bc5d1be2e99823b30618 100644 (file)
@@ -3,7 +3,7 @@
 // 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';
@@ -43,9 +43,9 @@ function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
        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();
 
@@ -62,7 +62,7 @@ describe('ChatFormContenteditable clipboard', () => {
        });
 
        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();
 
@@ -88,7 +88,7 @@ describe('ChatFormContenteditable clipboard', () => {
        });
 
        it('paste of markdown mention links re-renders badges', async () => {
-               const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+               const { container } = render(ChatFormContentEditable, { value: 'hello ' });
 
                await tick();
 
@@ -114,7 +114,7 @@ describe('ChatFormContenteditable clipboard', () => {
        });
 
        it('paste without mention links keeps the DOM untouched', async () => {
-               const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+               const { container } = render(ChatFormContentEditable, { value: 'hello ' });
 
                await tick();
 
@@ -138,9 +138,9 @@ describe('ChatFormContenteditable clipboard', () => {
        });
 });
 
-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();
 
@@ -153,7 +153,7 @@ describe('ChatFormContenteditable code spans', () => {
 
        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();
 
@@ -166,7 +166,7 @@ describe('ChatFormContenteditable code spans', () => {
 
        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();
 
@@ -183,7 +183,7 @@ describe('ChatFormContenteditable code spans', () => {
        });
 
        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();
 
@@ -210,7 +210,7 @@ describe('ChatFormContenteditable code spans', () => {
 
        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();
 
@@ -223,7 +223,7 @@ describe('ChatFormContenteditable code spans', () => {
        });
 
        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();
 
@@ -233,7 +233,7 @@ describe('ChatFormContenteditable code spans', () => {
        });
 });
 
-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"]';
 
@@ -273,7 +273,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        }
 
        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();
 
@@ -292,7 +292,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -318,7 +318,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -343,7 +343,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -358,7 +358,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -378,7 +378,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -397,7 +397,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -419,7 +419,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -437,7 +437,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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'
                });
 
@@ -469,7 +469,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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'
                });
 
@@ -501,7 +501,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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'
                });
 
@@ -534,7 +534,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -560,7 +560,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -581,7 +581,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        it('renders text after a block without a phantom empty line', async () => {
-               const { container } = render(ChatFormContenteditable, {
+               const { container } = render(ChatFormContentEditable, {
                        value: BLOCK_SOURCE + '\nhello'
                });
 
@@ -594,7 +594,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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'
                });
 
@@ -607,7 +607,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 
        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();
 
@@ -639,12 +639,12 @@ describe('ChatFormContenteditable code block escape hatches', () => {
        });
 });
 
-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
                });
@@ -679,7 +679,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
 
        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;'
                });
@@ -707,7 +707,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
 
        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'
                });
@@ -730,7 +730,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
 
        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
                });
@@ -753,7 +753,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
 
        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
                });
@@ -780,7 +780,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
 
        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'
                });
diff --git a/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte
new file mode 100644 (file)
index 0000000..21dddf5
--- /dev/null
@@ -0,0 +1,27 @@
+<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 />
diff --git a/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte
deleted file mode 100644 (file)
index fac7737..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<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 />
index ec75a0e492961ed9f80940ff16ed05b0eced5c07..d13b796c438132f1b71729fcf8efa2a10ec527e6 100644 (file)
@@ -1,6 +1,7 @@
 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(
index 3349b7ab6e1cddb8d94b175eafc0cfa742a27923..b0ec76be418afacdf7c89a7cb52c4444196d3375 100644 (file)
@@ -10,7 +10,7 @@ import {
        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';