refactor: Clean up UI types

This commit is contained in:
Aleksander Grygier
2026-08-12 17:37:51 +02:00
parent 1f4de0704c
commit 6175281c78
53 changed files with 384 additions and 290 deletions
@@ -3,7 +3,7 @@
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormContenteditable,
ChatFormContentEditable,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
@@ -605,7 +605,7 @@
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
@@ -2,7 +2,8 @@
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 type { ContentEditableToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
import {
badgeAwareWordJump,
buildFragment,
@@ -63,7 +64,7 @@
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
}
function renderTokens(tokens: ContentToken[]) {
function renderTokens(tokens: ContentEditableToken[]) {
if (!rootElement) return;
const caret = rangeToTextOffset(rootElement, safeRange());
@@ -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;
@@ -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';
@@ -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
@@ -11,10 +11,10 @@
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
@@ -12,13 +12,9 @@
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;
@@ -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';
@@ -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;
@@ -11,19 +11,19 @@
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 {
@@ -222,7 +222,7 @@
>
{#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}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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 {
@@ -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;
@@ -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 {
@@ -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;
@@ -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 };
@@ -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';
/**
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -17,12 +17,13 @@
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;
@@ -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;
@@ -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
+10
View 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'
}
+10 -2
View 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';
+10
View 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'
}
@@ -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 {
+59 -2
View 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 };
+11
View File
@@ -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 };
+67
View File
@@ -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;
}
+20 -1
View 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
+10
View File
@@ -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;
};
+1 -52
View 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.
@@ -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;
}
+9 -37
View 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 };
}
+4 -16
View 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
+2 -8
View File
@@ -1,3 +1,5 @@
import type { SearchResult } from '$lib/types/search';
/**
* Parsers for MCP web-search tool responses shaped like:
*
@@ -16,14 +18,6 @@
* 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
+1 -16
View 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,
@@ -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();
@@ -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();
@@ -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'
});
@@ -1,5 +1,5 @@
<script lang="ts">
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
import { untrack } from 'svelte';
interface Props {
@@ -9,7 +9,7 @@
let { value: initial = '' }: Props = $props();
let value = $state(untrack(() => initial));
let inputRef: ChatFormContenteditable | undefined = $state(undefined);
let inputRef: ChatFormContentEditable | undefined = $state(undefined);
export function getValue() {
return value;
@@ -24,4 +24,4 @@
}
</script>
<ChatFormContenteditable bind:this={inputRef} bind:value />
<ChatFormContentEditable bind:this={inputRef} bind:value />
@@ -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(
+1 -1
View 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';