From e20b672fb30c59078fc5dd62ab9cc7b98360f869 Mon Sep 17 00:00:00 2001 From: All Mightimus Prime <130307237+Allmight97@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:12:01 -0800 Subject: [PATCH 1/2] Add composer helpers and code block rendering --- src-tauri/src/types.rs | 75 +++ src/App.tsx | 33 ++ src/features/composer/components/Composer.tsx | 211 +++++++- .../components/ComposerEditorHelpers.test.tsx | 169 ++++++ .../composer/components/ComposerInput.tsx | 40 +- .../composer/hooks/useComposerEditorState.ts | 30 ++ src/features/layout/hooks/useLayoutNodes.tsx | 9 + src/features/messages/components/Markdown.tsx | 251 ++++++--- src/features/messages/components/Messages.tsx | 7 + .../settings/components/SettingsView.test.tsx | 72 +-- .../settings/components/SettingsView.tsx | 481 +++++++++++------- src/features/settings/hooks/useAppSettings.ts | 9 + src/styles/composer.css | 2 +- src/styles/messages.css | 59 +++ src/types.ts | 23 + src/utils/composerText.ts | 125 +++++ 16 files changed, 1275 insertions(+), 321 deletions(-) create mode 100644 src/features/composer/components/ComposerEditorHelpers.test.tsx create mode 100644 src/features/composer/hooks/useComposerEditorState.ts create mode 100644 src/utils/composerText.ts diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index e07d3e6bf..1574b5363 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -367,6 +367,27 @@ pub(crate) struct AppSettings { rename = "dictationHoldKey" )] pub(crate) dictation_hold_key: String, + #[serde(default = "default_composer_editor_preset", rename = "composerEditorPreset")] + pub(crate) composer_editor_preset: String, + #[serde(default = "default_composer_fence_expand_on_space", rename = "composerFenceExpandOnSpace")] + pub(crate) composer_fence_expand_on_space: bool, + #[serde(default = "default_composer_fence_expand_on_enter", rename = "composerFenceExpandOnEnter")] + pub(crate) composer_fence_expand_on_enter: bool, + #[serde(default = "default_composer_fence_language_tags", rename = "composerFenceLanguageTags")] + pub(crate) composer_fence_language_tags: bool, + #[serde(default = "default_composer_fence_wrap_selection", rename = "composerFenceWrapSelection")] + pub(crate) composer_fence_wrap_selection: bool, + #[serde(default = "default_composer_fence_auto_wrap_paste_multiline", rename = "composerFenceAutoWrapPasteMultiline")] + pub(crate) composer_fence_auto_wrap_paste_multiline: bool, + #[serde(default = "default_composer_fence_auto_wrap_paste_code_like", rename = "composerFenceAutoWrapPasteCodeLike")] + pub(crate) composer_fence_auto_wrap_paste_code_like: bool, + #[serde(default = "default_composer_list_continuation", rename = "composerListContinuation")] + pub(crate) composer_list_continuation: bool, + #[serde( + default = "default_composer_code_block_copy_use_modifier", + rename = "composerCodeBlockCopyUseModifier" + )] + pub(crate) composer_code_block_copy_use_modifier: bool, #[serde(default = "default_workspace_groups", rename = "workspaceGroups")] pub(crate) workspace_groups: Vec, } @@ -497,6 +518,42 @@ fn default_dictation_hold_key() -> String { "alt".to_string() } +fn default_composer_editor_preset() -> String { + "default".to_string() +} + +fn default_composer_fence_expand_on_space() -> bool { + false +} + +fn default_composer_fence_expand_on_enter() -> bool { + false +} + +fn default_composer_fence_language_tags() -> bool { + false +} + +fn default_composer_fence_wrap_selection() -> bool { + false +} + +fn default_composer_fence_auto_wrap_paste_multiline() -> bool { + false +} + +fn default_composer_fence_auto_wrap_paste_code_like() -> bool { + false +} + +fn default_composer_list_continuation() -> bool { + false +} + +fn default_composer_code_block_copy_use_modifier() -> bool { + false +} + fn default_workspace_groups() -> Vec { Vec::new() } @@ -538,6 +595,15 @@ impl Default for AppSettings { dictation_model_id: default_dictation_model_id(), dictation_preferred_language: None, dictation_hold_key: default_dictation_hold_key(), + composer_editor_preset: default_composer_editor_preset(), + composer_fence_expand_on_space: default_composer_fence_expand_on_space(), + composer_fence_expand_on_enter: default_composer_fence_expand_on_enter(), + composer_fence_language_tags: default_composer_fence_language_tags(), + composer_fence_wrap_selection: default_composer_fence_wrap_selection(), + composer_fence_auto_wrap_paste_multiline: default_composer_fence_auto_wrap_paste_multiline(), + composer_fence_auto_wrap_paste_code_like: default_composer_fence_auto_wrap_paste_code_like(), + composer_list_continuation: default_composer_list_continuation(), + composer_code_block_copy_use_modifier: default_composer_code_block_copy_use_modifier(), workspace_groups: default_workspace_groups(), } } @@ -606,6 +672,15 @@ mod tests { assert_eq!(settings.dictation_model_id, "base"); assert!(settings.dictation_preferred_language.is_none()); assert_eq!(settings.dictation_hold_key, "alt"); + assert_eq!(settings.composer_editor_preset, "default"); + assert!(!settings.composer_fence_expand_on_space); + assert!(!settings.composer_fence_expand_on_enter); + assert!(!settings.composer_fence_language_tags); + assert!(!settings.composer_fence_wrap_selection); + assert!(!settings.composer_fence_auto_wrap_paste_multiline); + assert!(!settings.composer_fence_auto_wrap_paste_code_like); + assert!(!settings.composer_list_continuation); + assert!(!settings.composer_code_block_copy_use_modifier); assert!(settings.workspace_groups.is_empty()); } diff --git a/src/App.tsx b/src/App.tsx index 963f5b09b..deab2e6ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { useAppSettingsController } from "./features/app/hooks/useAppSettingsCon import { useUpdaterController } from "./features/app/hooks/useUpdaterController"; import { useComposerShortcuts } from "./features/composer/hooks/useComposerShortcuts"; import { useComposerMenuActions } from "./features/composer/hooks/useComposerMenuActions"; +import { useComposerEditorState } from "./features/composer/hooks/useComposerEditorState"; import { useDictationController } from "./features/app/hooks/useDictationController"; import { useComposerController } from "./features/app/hooks/useComposerController"; import { useRenameThreadPrompt } from "./features/threads/hooks/useRenameThreadPrompt"; @@ -83,6 +84,7 @@ import { useGitCommitController } from "./features/app/hooks/useGitCommitControl import { pickWorkspacePath } from "./services/tauri"; import type { AccessMode, + ComposerEditorSettings, WorkspaceInfo, } from "./types"; @@ -504,6 +506,33 @@ function MainApp() { queueSaveSettings, }); + const { isExpanded: composerEditorExpanded, toggleExpanded: toggleComposerEditorExpanded } = + useComposerEditorState(); + + const composerEditorSettings = useMemo( + () => ({ + preset: appSettings.composerEditorPreset, + expandFenceOnSpace: appSettings.composerFenceExpandOnSpace, + expandFenceOnEnter: appSettings.composerFenceExpandOnEnter, + fenceLanguageTags: appSettings.composerFenceLanguageTags, + fenceWrapSelection: appSettings.composerFenceWrapSelection, + autoWrapPasteMultiline: appSettings.composerFenceAutoWrapPasteMultiline, + autoWrapPasteCodeLike: appSettings.composerFenceAutoWrapPasteCodeLike, + continueListOnShiftEnter: appSettings.composerListContinuation, + }), + [ + appSettings.composerEditorPreset, + appSettings.composerFenceExpandOnSpace, + appSettings.composerFenceExpandOnEnter, + appSettings.composerFenceLanguageTags, + appSettings.composerFenceWrapSelection, + appSettings.composerFenceAutoWrapPasteMultiline, + appSettings.composerFenceAutoWrapPasteCodeLike, + appSettings.composerListContinuation, + ], + ); + + useSyncSelectedDiffPath({ diffSource, centerMode, @@ -1276,6 +1305,7 @@ function MainApp() { activeThreadId, activeItems, activeRateLimits, + codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier, approvals, handleApprovalDecision, handleApprovalRemember, @@ -1549,6 +1579,9 @@ function MainApp() { prompts, files, textareaRef: composerInputRef, + composerEditorSettings, + composerEditorExpanded, + onToggleComposerEditorExpanded: toggleComposerEditorExpanded, dictationEnabled: appSettings.dictationEnabled && dictationReady, dictationState, dictationLevel, diff --git a/src/features/composer/components/Composer.tsx b/src/features/composer/components/Composer.tsx index 0910ce6be..f9997bba5 100644 --- a/src/features/composer/components/Composer.tsx +++ b/src/features/composer/components/Composer.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from "react"; import type { + ComposerEditorSettings, CustomPromptOption, DictationTranscript, QueuedMessage, @@ -7,6 +8,14 @@ import type { } from "../../../types"; import { computeDictationInsertion } from "../../../utils/dictation"; import { isComposingEvent } from "../../../utils/keys"; +import { + getFenceTriggerLine, + getLineIndent, + getListContinuation, + isCodeLikeSingleLine, + isCursorInsideFence, + normalizePastedText, +} from "../../../utils/composerText"; import { useComposerAutocompleteState } from "../hooks/useComposerAutocompleteState"; import { ComposerInput } from "./ComposerInput"; import { ComposerMetaBar } from "./ComposerMetaBar"; @@ -50,6 +59,9 @@ type ComposerProps = { insertText?: QueuedMessage | null; onInsertHandled?: (id: string) => void; textareaRef?: React.RefObject; + editorSettings?: ComposerEditorSettings; + editorExpanded?: boolean; + onToggleEditorExpanded?: () => void; dictationEnabled?: boolean; dictationState?: "idle" | "listening" | "processing"; dictationLevel?: number; @@ -63,6 +75,17 @@ type ComposerProps = { onDismissDictationHint?: () => void; }; +const DEFAULT_EDITOR_SETTINGS: ComposerEditorSettings = { + preset: "default", + expandFenceOnSpace: false, + expandFenceOnEnter: false, + fenceLanguageTags: false, + fenceWrapSelection: false, + autoWrapPasteMultiline: false, + autoWrapPasteCodeLike: false, + continueListOnShiftEnter: false, +}; + export function Composer({ onSend, onQueue, @@ -101,6 +124,9 @@ export function Composer({ insertText = null, onInsertHandled, textareaRef: externalTextareaRef, + editorSettings: editorSettingsProp, + editorExpanded = false, + onToggleEditorExpanded, dictationEnabled = false, dictationState = "idle", dictationLevel = 0, @@ -117,8 +143,18 @@ export function Composer({ const [selectionStart, setSelectionStart] = useState(null); const internalRef = useRef(null); const textareaRef = externalTextareaRef ?? internalRef; + const editorSettings = editorSettingsProp ?? DEFAULT_EDITOR_SETTINGS; const isDictationBusy = dictationState !== "idle"; const canSend = text.trim().length > 0 || attachedImages.length > 0; + const { + expandFenceOnSpace, + expandFenceOnEnter, + fenceLanguageTags, + fenceWrapSelection, + autoWrapPasteMultiline, + autoWrapPasteCodeLike, + continueListOnShiftEnter, + } = editorSettings; useEffect(() => { setText((prev) => (prev === draftText ? prev : draftText)); @@ -231,6 +267,120 @@ export function Composer({ textareaRef, ]); + const applyTextInsertion = useCallback( + (nextText: string, nextCursor: number) => { + setComposerText(nextText); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + textarea.setSelectionRange(nextCursor, nextCursor); + handleSelectionChange(nextCursor); + }); + }, + [handleSelectionChange, setComposerText, textareaRef], + ); + + const handleTextPaste = useCallback( + (event: ClipboardEvent) => { + if (disabled) { + return; + } + if (!autoWrapPasteMultiline && !autoWrapPasteCodeLike) { + return; + } + const pasted = event.clipboardData?.getData("text/plain") ?? ""; + if (!pasted) { + return; + } + const textarea = textareaRef.current; + if (!textarea) { + return; + } + const start = textarea.selectionStart ?? text.length; + const end = textarea.selectionEnd ?? start; + if (isCursorInsideFence(text, start)) { + return; + } + const normalized = normalizePastedText(pasted); + if (!normalized) { + return; + } + const isMultiline = normalized.includes("\n"); + if (isMultiline && !autoWrapPasteMultiline) { + return; + } + if ( + !isMultiline && + !(autoWrapPasteCodeLike && isCodeLikeSingleLine(normalized)) + ) { + return; + } + event.preventDefault(); + const indent = getLineIndent(text, start); + const content = indent + ? normalized + .split("\n") + .map((line) => `${indent}${line}`) + .join("\n") + : normalized; + const before = text.slice(0, start); + const after = text.slice(end); + const block = `${indent}\`\`\`\n${content}\n${indent}\`\`\``; + const nextText = `${before}${block}${after}`; + const nextCursor = before.length + block.length; + applyTextInsertion(nextText, nextCursor); + }, + [ + applyTextInsertion, + autoWrapPasteCodeLike, + autoWrapPasteMultiline, + disabled, + text, + textareaRef, + ], + ); + + const tryExpandFence = useCallback( + (start: number, end: number) => { + if (start !== end && !fenceWrapSelection) { + return false; + } + const fence = getFenceTriggerLine(text, start, fenceLanguageTags); + if (!fence) { + return false; + } + const before = text.slice(0, fence.lineStart); + const after = text.slice(fence.lineEnd); + const openFence = `${fence.indent}\`\`\`${fence.tag}`; + const closeFence = `${fence.indent}\`\`\``; + if (fenceWrapSelection && start !== end) { + const selection = normalizePastedText(text.slice(start, end)); + const content = fence.indent + ? selection + .split("\n") + .map((line) => `${fence.indent}${line}`) + .join("\n") + : selection; + const block = `${openFence}\n${content}\n${closeFence}`; + const nextText = `${before}${block}${after}`; + const nextCursor = before.length + block.length; + applyTextInsertion(nextText, nextCursor); + return true; + } + const block = `${openFence}\n${fence.indent}\n${closeFence}`; + const nextText = `${before}${block}${after}`; + const nextCursor = + before.length + openFence.length + 1 + fence.indent.length; + applyTextInsertion(nextText, nextCursor); + return true; + }, + [applyTextInsertion, fenceLanguageTags, fenceWrapSelection, text], + ); + + return (