diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte
index 98b082c891..35977dfc39 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte
@@ -797,7 +797,7 @@
data-placeholder={placeholder}
tabindex={disabled ? -1 : 0}
class={[
- 'chat-form-contenteditable text-md min-h-12 w-full 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',
+ '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);"
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
index 5a1e4f2138..30605ba9f1 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
@@ -164,7 +164,7 @@
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
- {#if !currentConfig.renderContentAsRawText}
+ {#if currentConfig.renderUserContentAsMarkdown}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
index a2e91b94c6..f5a3ff6f98 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
@@ -1,5 +1,5 @@
+
+
+
+
+
+ {label}
+
diff --git a/tools/ui/src/lib/components/app/content/MentionText.svelte b/tools/ui/src/lib/components/app/content/MentionText.svelte
new file mode 100644
index 0000000000..0a4bc0eebe
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MentionText.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+{#each segments as segment, index (index)}{#if segment.mention}{:else}{segment.text}{/if}{/each}
diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts
index 5cfdd1b9c1..9b43fe9cca 100644
--- a/tools/ui/src/lib/components/app/content/index.ts
+++ b/tools/ui/src/lib/components/app/content/index.ts
@@ -31,6 +31,20 @@
*/
export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte';
+/**
+ * **MentionText** - Plain text with file mention badges
+ *
+ * Renders a message verbatim, turning only `[name](file://path)` links
+ * into the same badge chips the markdown path draws. Nothing else is
+ * interpreted, so pasted code keeps its `#` comments and underscores.
+ *
+ * @example
+ * ```svelte
+ *
+ * ```
+ */
+export { default as MentionText } from './MentionText.svelte';
+
/**
* **SyntaxHighlightedCode** - Code syntax highlighting
*
diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.ts
index 5de8270140..a9ef963c39 100644
--- a/tools/ui/src/lib/constants/mention-badge.ts
+++ b/tools/ui/src/lib/constants/mention-badge.ts
@@ -10,6 +10,9 @@ export const MENTION_BADGE_CLASSNAME =
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
+/** Regex flag that makes the mention scanner walk every link in a message instead of the first. */
+export const MENTION_LINK_SCAN_FLAGS = 'g';
+
/**
* SVG attributes shared by the DOM-built and hast-built badge icons.
* The tokenizer applies them via `setAttribute`, the rehype plugin
diff --git a/tools/ui/src/lib/constants/path-display.ts b/tools/ui/src/lib/constants/path-display.ts
index 95877d741e..fd10017613 100644
--- a/tools/ui/src/lib/constants/path-display.ts
+++ b/tools/ui/src/lib/constants/path-display.ts
@@ -12,6 +12,9 @@ import { UrlProtocol } from '$lib/enums';
export const CWD_CHANGED_PREFIX = 'Set working directory to ';
export const CWD_CLEARED_TEXT = 'Working directory cleared';
+/** Trailing separator that marks a path as a directory. */
+export const DIRECTORY_PATH_SUFFIX = '/';
+
export const HOME_TILDE = '~';
export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts
index 8388f2b759..b53d11048d 100644
--- a/tools/ui/src/lib/constants/settings-keys.ts
+++ b/tools/ui/src/lib/constants/settings-keys.ts
@@ -41,7 +41,8 @@ export const SETTINGS_KEYS = {
// Performance
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
PRESENCE_PENALTY: 'presence_penalty',
- RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
+ RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
+ RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
// Penalties
REPEAT_LAST_N: 'repeat_last_n',
REPEAT_PENALTY: 'repeat_penalty',
diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts
index 37f7c8f310..ada029a33f 100644
--- a/tools/ui/src/lib/constants/settings-registry.ts
+++ b/tools/ui/src/lib/constants/settings-registry.ts
@@ -230,10 +230,18 @@ const SETTINGS_REGISTRY: Record = {
type: SettingsFieldType.CHECKBOX
},
{
- defaultValue: false,
- help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
- key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
- label: 'Render content as raw text',
+ defaultValue: true,
+ help: 'Render user messages using markdown formatting in the chat. Turn this off to keep a message exactly as typed; @-mention badges show either way.',
+ key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
+ label: 'Render user content as Markdown',
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: true,
+ help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
+ key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
+ label: 'Render thinking as Markdown',
section: SETTINGS_SECTION_SLUGS.DISPLAY,
type: SettingsFieldType.CHECKBOX
},
diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts
index b2626c794a..ffe4bd8467 100644
--- a/tools/ui/src/lib/services/migration.service.ts
+++ b/tools/ui/src/lib/services/migration.service.ts
@@ -629,6 +629,37 @@ const configTypesMigration: Migration = {
console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
}
};
+const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
+const LEGACY_RENDER_RAW_TEXT_KEY = 'renderContentAsRawText';
+const renderKeysMigration: Migration = {
+ description: 'Unfold the single raw text render toggle onto the per-surface render keys',
+ id: RENDER_KEYS_MIGRATION_ID,
+
+ async run(): Promise {
+ const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
+ if (configRaw === null) return;
+
+ const config = JSON.parse(configRaw);
+
+ if (!(LEGACY_RENDER_RAW_TEXT_KEY in config)) return;
+
+ // The toggle carried user content and thinking at once and cannot say which surface
+ // was chosen, so it only restores the user key and thinking keeps its own default.
+ if (!(SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN in config)) {
+ config[SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN] =
+ config[LEGACY_RENDER_RAW_TEXT_KEY] !== true;
+ }
+
+ // Dropped rather than preserved: the two render keys and the toggle describe the same
+ // surfaces, so leaving it behind would let a stale value fight the restored one.
+ delete config[LEGACY_RENDER_RAW_TEXT_KEY];
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+
+ if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+ console.log('[Migration] Render keys: unfolded the raw text toggle');
+ }
+};
const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
/**
@@ -722,7 +753,8 @@ const migrations: Migration[] = [
customJsonKeyMigration,
mcpDefaultEnabledMigration,
mcpDefaultOverridesMergeMigration,
- configTypesMigration
+ configTypesMigration,
+ renderKeysMigration
];
export const MigrationService = {
diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts
index f413638efa..c8b9be7a78 100644
--- a/tools/ui/src/lib/stores/settings.svelte.ts
+++ b/tools/ui/src/lib/stores/settings.svelte.ts
@@ -135,32 +135,6 @@ class SettingsStore {
...savedVal
};
- // Migrate the legacy render keys into `renderContentAsRawText`
- // (inverted semantics: the old keys opted INTO markdown). Any
- // explicit raw-text preference wins when the legacy keys disagree.
- const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
- const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
- const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
- (key) => key in savedVal
- );
-
- if (legacyKeys.length > 0) {
- if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
- if (LEGACY_RAW_TEXT_KEY in savedVal) {
- this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
- } else {
- this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
- (key) => key in savedVal
- ).some((key) => savedVal[key] === false);
- }
- }
-
- for (const key of legacyKeys) {
- delete (this.config as Record)[key];
- }
- this.saveConfig();
- }
-
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) {
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 6703da373a..5762e8e4bb 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -240,6 +240,7 @@ export {
MENTION_BADGE_FOLDER_ICON_PATHS,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
+ splitMentionSegments,
buildMentionInsertion
} from './mention-badge';
diff --git a/tools/ui/src/lib/utils/mention-badge.ts b/tools/ui/src/lib/utils/mention-badge.ts
index 7b76a22800..5b1b7d1fe5 100644
--- a/tools/ui/src/lib/utils/mention-badge.ts
+++ b/tools/ui/src/lib/utils/mention-badge.ts
@@ -1,8 +1,9 @@
import { abbreviateHome, lastPathSegment } from './path-display';
-import { FILE_URI_PREFIX } from '$lib/constants';
+import { DIRECTORY_PATH_SUFFIX, FILE_URI_PREFIX } from '$lib/constants';
import {
MENTION_BADGE_FILE_ICON_PATHS,
- MENTION_BADGE_FOLDER_ICON_PATHS
+ MENTION_BADGE_FOLDER_ICON_PATHS,
+ MENTION_LINK_SCAN_FLAGS
} from '$lib/constants/mention-badge';
import { FileMentionEntryType } from '$lib/enums';
import type { FileMentionEntry } from '$lib/types';
@@ -48,8 +49,45 @@ export function decodeFileLinkPath(path: string): string {
}
}
+export interface MentionTextSegment {
+ text: string;
+ mention: { name: string; path: string } | null;
+}
+
+/**
+ * Split raw text into plain runs and `[name](file://path)` mentions.
+ * The raw-text renderers walk these segments to draw badges without
+ * handing the message to the markdown parser, so a `#` stays a `#`.
+ */
+export function splitMentionSegments(value: string): MentionTextSegment[] {
+ const linkRe = fileMentionLinkRe(MENTION_LINK_SCAN_FLAGS);
+ const segments: MentionTextSegment[] = [];
+
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = linkRe.exec(value)) !== null) {
+ if (match.index > cursor) {
+ segments.push({ mention: null, text: value.slice(cursor, match.index) });
+ }
+
+ segments.push({
+ mention: { name: match[1], path: decodeFileLinkPath(match[2]) },
+ text: match[0]
+ });
+
+ cursor = match.index + match[0].length;
+ }
+
+ if (cursor < value.length) segments.push({ mention: null, text: value.slice(cursor) });
+
+ return segments;
+}
+
export function getMentionBadgeIconPaths(path: string): readonly string[] {
- return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
+ return path.endsWith(DIRECTORY_PATH_SUFFIX)
+ ? MENTION_BADGE_FOLDER_ICON_PATHS
+ : MENTION_BADGE_FILE_ICON_PATHS;
}
export function getMentionBadgeLabel(
diff --git a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts b/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts
deleted file mode 100644
index 7e60ccfb9c..0000000000
--- a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
-// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
-// `renderContentAsRawText` setting, with any explicit raw-text preference
-// winning when the legacy keys disagree. Legacy keys are removed from the
-// persisted config so they do not stay orphaned in localStorage.
-
-import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
-import { beforeEach, describe, expect, it } from 'vitest';
-
-function seedConfig(stored: Record) {
- localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
- settingsStore.initialize();
-}
-
-function persisted(): Record {
- return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
-}
-
-describe('renderContentAsRawText migration', () => {
- beforeEach(() => {
- localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
- settingsStore.initialize();
- });
-
- it('maps renderUserContentAsMarkdown=false to raw text', () => {
- seedConfig({ renderUserContentAsMarkdown: false });
- expect(config().renderContentAsRawText).toBe(true);
- });
-
- it('maps renderUserContentAsMarkdown=true to markdown', () => {
- seedConfig({ renderUserContentAsMarkdown: true });
- expect(config().renderContentAsRawText).toBe(false);
- });
-
- it('maps renderThinkingAsMarkdown=false to raw text', () => {
- seedConfig({ renderThinkingAsMarkdown: false });
- expect(config().renderContentAsRawText).toBe(true);
- });
-
- it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
- seedConfig({ renderThinkingAsMarkdown: false, renderUserContentAsMarkdown: true });
- expect(config().renderContentAsRawText).toBe(true);
- });
-
- it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
- seedConfig({ renderUserContentAsRawText: true });
- expect(config().renderContentAsRawText).toBe(true);
- });
-
- it('keeps an already-migrated value and cleans up the legacy keys', () => {
- seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
- expect(config().renderContentAsRawText).toBe(false);
-
- const stored = persisted();
-
- expect(stored.renderUserContentAsMarkdown).toBeUndefined();
- expect(stored.renderThinkingAsMarkdown).toBeUndefined();
- expect(stored.renderUserContentAsRawText).toBeUndefined();
- });
-
- it('defaults to markdown when no legacy key exists', () => {
- seedConfig({});
- expect(config().renderContentAsRawText).toBe(false);
- });
-});
diff --git a/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts
new file mode 100644
index 0000000000..380a2e74d0
--- /dev/null
+++ b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts
@@ -0,0 +1,64 @@
+// Guards the unfolding of `renderContentAsRawText` back onto the two
+// per-surface render keys. The single toggle carried user content and
+// thinking at once, so only the user key is restored from it and thinking
+// returns to its own default. The toggle is removed from the persisted
+// config so it does not stay orphaned in localStorage.
+
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { MigrationService } from '$lib/services/migration.service';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
+
+async function seedConfig(stored: Record) {
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
+
+ const migration = MigrationService.getMigrations().find((m) => m.id === RENDER_KEYS_MIGRATION_ID);
+
+ await migration?.run();
+ settingsStore.initialize();
+}
+
+function persisted(): Record {
+ return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+}
+
+describe('renderContentAsRawText unfolding', () => {
+ beforeEach(() => {
+ localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
+ MigrationService.resetState();
+ settingsStore.initialize();
+ });
+
+ it('maps raw text to user content as plain text', async () => {
+ await seedConfig({ renderContentAsRawText: true });
+ expect(config().renderUserContentAsMarkdown).toBe(false);
+ });
+
+ it('maps markdown to user content as markdown', async () => {
+ await seedConfig({ renderContentAsRawText: false });
+ expect(config().renderUserContentAsMarkdown).toBe(true);
+ });
+
+ it('leaves thinking on its own default', async () => {
+ await seedConfig({ renderContentAsRawText: true });
+ expect(config().renderThinkingAsMarkdown).toBe(true);
+ });
+
+ it('keeps an explicit user preference over the toggle', async () => {
+ await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true });
+ expect(config().renderUserContentAsMarkdown).toBe(true);
+ });
+
+ it('drops the toggle from the persisted config', async () => {
+ await seedConfig({ renderContentAsRawText: true });
+ expect(persisted().renderContentAsRawText).toBeUndefined();
+ });
+
+ it('leaves both surfaces on markdown when nothing is stored', async () => {
+ await seedConfig({});
+ expect(config().renderUserContentAsMarkdown).toBe(true);
+ expect(config().renderThinkingAsMarkdown).toBe(true);
+ });
+});
diff --git a/tools/ui/tests/unit/mention-segments.test.ts b/tools/ui/tests/unit/mention-segments.test.ts
new file mode 100644
index 0000000000..51bb46d1e4
--- /dev/null
+++ b/tools/ui/tests/unit/mention-segments.test.ts
@@ -0,0 +1,48 @@
+import { splitMentionSegments } from '$lib/utils/mention-badge';
+import { describe, expect, it } from 'vitest';
+
+describe('splitMentionSegments', () => {
+ it('returns a single plain run when there is no mention', () => {
+ const segments = splitMentionSegments('# not a heading here');
+
+ expect(segments).toEqual([{ mention: null, text: '# not a heading here' }]);
+ });
+
+ it('splits text around a mention', () => {
+ const segments = splitMentionSegments('look at [main.c](file:///src/main.c) please');
+
+ expect(segments.map((segment) => segment.text)).toEqual([
+ 'look at ',
+ '[main.c](file:///src/main.c)',
+ ' please'
+ ]);
+ expect(segments[1].mention).toEqual({ name: 'main.c', path: '/src/main.c' });
+ });
+
+ it('decodes percent-encoded paths', () => {
+ const segments = splitMentionSegments('[a b.txt](file:///tmp/a%20b.txt)');
+
+ expect(segments[0].mention?.path).toBe('/tmp/a b.txt');
+ });
+
+ it('keeps the directory marker so the folder icon is picked', () => {
+ const segments = splitMentionSegments('[src](file:///repo/src/)');
+
+ expect(segments[0].mention?.path).toBe('/repo/src/');
+ });
+
+ it('handles adjacent mentions with no text between them', () => {
+ const segments = splitMentionSegments('[a](file:///a)[b](file:///b)');
+
+ expect(segments).toHaveLength(2);
+ expect(segments.every((segment) => segment.mention !== null)).toBe(true);
+ });
+
+ it('preserves the exact source when segments are joined back', () => {
+ const source = 'see [a](file:///a) and [b](file:///b/) done';
+
+ expect(splitMentionSegments(source).reduce((acc, segment) => acc + segment.text, '')).toBe(
+ source
+ );
+ });
+});