feat: add code block line wrap toggle

Adds a chat code block wrap toggle in markdown code block headers
Persists and restores the setting across desktop/web settings
Adds localized labels and OpenChamber search entry for the new option
This commit is contained in:
Bohdan Triapitsyn
2026-07-08 14:46:44 +03:00
parent 859b4529da
commit 8b7448bcf0
30 changed files with 166 additions and 9 deletions
@@ -23,6 +23,7 @@ import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCor
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
attachMarkdownInteractions,
applyMarkdownCodeBlockWrapState,
decorateMarkdown,
type DecorateContext,
type DecorateLabels,
@@ -863,6 +864,8 @@ const useDecorateContext = (
const labels: DecorateLabels = React.useMemo(() => ({
copy: 'Copy code',
copied: 'Copied',
enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
copyTable: t('markdownRenderer.table.actions.copyTitle'),
downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
@@ -871,6 +874,12 @@ const useDecorateContext = (
previewTitle: t('terminalView.preview.openTitle'),
}), [t]);
const codeBlockLineWrap = useUIStore((state) => state.codeBlockLineWrap);
const setCodeBlockLineWrap = useUIStore((state) => state.setCodeBlockLineWrap);
const toggleCodeBlockLineWrap = React.useCallback(() => {
setCodeBlockLineWrap(!useUIStore.getState().codeBlockLineWrap);
}, [setCodeBlockLineWrap]);
return React.useMemo<DecorateContext>(() => {
const colors = mermaidColorsFromTheme(currentTheme);
const mode = useUIStore.getState().mermaidRenderingMode;
@@ -884,8 +893,8 @@ const useDecorateContext = (
return {};
}
});
return { labels, renderMermaid, onPreviewLoopback };
}, [currentTheme, labels, onPreviewLoopback]);
return { labels, codeBlockLineWrap, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
}, [currentTheme, labels, codeBlockLineWrap, toggleCodeBlockLineWrap, onPreviewLoopback]);
};
// Runs the async render pipeline into the container and keeps a stable
@@ -994,6 +1003,13 @@ const useMorphdomMarkdown = ({
target.style.setProperty(key, value);
}
}, [containerRef, syntaxVars]);
React.useEffect(() => {
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
}, [containerRef, ctx.codeBlockLineWrap, ctx.labels]);
};
const markdownContentClassName = (variant: MarkdownVariant): string =>
@@ -11,6 +11,8 @@ export type MermaidRender = { svg?: string; ascii?: string };
export type DecorateLabels = {
copy: string;
copied: string;
enableCodeWrap: string;
disableCodeWrap: string;
copyTable: string;
downloadTable: string;
copyDiagram: string;
@@ -21,6 +23,8 @@ export type DecorateLabels = {
export type DecorateContext = {
labels: DecorateLabels;
codeBlockLineWrap: boolean;
onToggleCodeBlockLineWrap?: () => void;
// Renders a mermaid block source to svg/ascii using current theme colors.
renderMermaid: (source: string) => MermaidRender;
onPreviewLoopback?: (url: string) => void;
@@ -36,6 +40,7 @@ const ICONS = {
copy: spriteIcon('file-copy'),
check: spriteIcon('check'),
download: spriteIcon('download'),
textWrap: spriteIcon('text-wrap'),
} as const;
const ICON_BTN_CLASS =
@@ -56,6 +61,37 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
return button;
};
const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: DecorateLabels): void => {
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
const pre = wrapper.querySelector<HTMLElement>('pre');
const code = wrapper.querySelector<HTMLElement>('pre code');
const wrapButton = wrapper.querySelector<HTMLButtonElement>('[data-md-action="toggle-code-wrap"]');
wrapper.setAttribute('data-code-wrap', enabled ? 'true' : 'false');
body?.classList.toggle('overflow-x-auto', !enabled);
body?.classList.toggle('overflow-x-hidden', enabled);
pre?.classList.toggle('whitespace-pre-wrap', enabled);
pre?.classList.toggle('break-words', enabled);
code?.classList.toggle('whitespace-pre-wrap', enabled);
code?.classList.toggle('break-words', enabled);
if (wrapButton) {
const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap;
wrapButton.setAttribute('title', title);
wrapButton.setAttribute('aria-label', title);
wrapButton.classList.toggle('text-foreground', enabled);
wrapButton.classList.toggle('opacity-100', enabled);
wrapButton.classList.toggle('text-muted-foreground', !enabled);
wrapButton.classList.toggle('opacity-65', !enabled);
wrapButton.setAttribute('aria-pressed', enabled ? 'true' : 'false');
}
};
export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: boolean, labels: DecorateLabels): void => {
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]');
for (const wrapper of Array.from(wrappers)) {
applyCodeBlockWrapState(wrapper, enabled, labels);
}
};
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
setHtml(button, ICONS.check);
button.setAttribute('title', copiedTitle);
@@ -78,7 +114,7 @@ const decorateInlineCode = (root: HTMLElement): void => {
}
};
const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void => {
const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
const blocks = root.querySelectorAll<HTMLPreElement>('pre');
for (const pre of Array.from(blocks)) {
// Skip mermaid placeholders (handled separately).
@@ -104,11 +140,17 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
const langLabel = document.createElement('span');
langLabel.className = 'font-mono text-[13px] text-muted-foreground';
langLabel.textContent = language;
const copyBtn = makeIconButton('copy', labels.copy, 'copy-code');
const copyBtn = makeIconButton('copy', ctx.labels.copy, 'copy-code');
const wrapBtn = makeIconButton('textWrap', ctx.codeBlockLineWrap ? ctx.labels.disableCodeWrap : ctx.labels.enableCodeWrap, 'toggle-code-wrap');
header.appendChild(langLabel);
header.appendChild(copyBtn);
const actions = document.createElement('div');
actions.className = 'flex items-center gap-1';
actions.appendChild(wrapBtn);
actions.appendChild(copyBtn);
header.appendChild(actions);
const body = document.createElement('div');
body.setAttribute('data-md-code-body', '');
body.className = 'px-3 py-2.5 overflow-x-auto';
parent.replaceChild(wrapper, pre);
@@ -117,6 +159,7 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels);
}
};
@@ -332,7 +375,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
decorateInlineCode(root);
decorateMermaid(root, ctx);
decorateCodeBlocks(root, ctx.labels);
decorateCodeBlocks(root, ctx);
decorateTables(root, ctx.labels);
decorateLinks(root, ctx);
};
@@ -387,6 +430,12 @@ export const attachMarkdownInteractions = (
return;
}
if (action === 'toggle-code-wrap') {
event.preventDefault();
ctx.onToggleCodeBlockLineWrap?.();
return;
}
// Toggle table menus
if (action === 'table-copy-toggle' || action === 'table-download-toggle') {
event.preventDefault();
@@ -144,7 +144,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
return <OpenChamberVisualSettings visibleSettings={['sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'codeBlockLineWrap', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
};
// Sessions section: Default model & agent, Session retention
@@ -245,7 +245,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -279,6 +279,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
const setWideChatLayoutEnabled = useUIStore(state => state.setWideChatLayoutEnabled);
const codeBlockLineWrap = useUIStore(state => state.codeBlockLineWrap);
const setCodeBlockLineWrap = useUIStore(state => state.setCodeBlockLineWrap);
const chatRenderMode = useUIStore(state => state.chatRenderMode);
const setChatRenderMode = useUIStore(state => state.setChatRenderMode);
const activityRenderMode = useUIStore(state => state.activityRenderMode);
@@ -561,6 +563,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('collapsibleUserMessages')
|| shouldShow('stickyUserHeader')
|| shouldShow('wideChatLayout')
|| shouldShow('codeBlockLineWrap')
|| shouldShow('splitAssistantMessageActions')
|| shouldShow('diffLayout')
|| shouldShow('dotfiles')
@@ -1779,7 +1782,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('codeBlockLineWrap') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
<section className="p-2 space-y-0.5">
{shouldShow('sessionAssist') && (
<>
@@ -1980,6 +1983,30 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{shouldShow('codeBlockLineWrap') && (
<div
data-settings-item="chat.code-block-line-wrap"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={codeBlockLineWrap}
onClick={() => setCodeBlockLineWrap(!codeBlockLineWrap)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setCodeBlockLineWrap(!codeBlockLineWrap);
}
}}
>
<Checkbox
checked={codeBlockLineWrap}
onChange={setCodeBlockLineWrap}
ariaLabel={t('settings.openchamber.visual.field.codeBlockLineWrapAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.codeBlockLineWrap')}</span>
</div>
)}
{shouldShow('showToolFileIcons') && (
<div
data-settings-item="chat.tool-file-icons"