Skip to content
28 changes: 27 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { shortcutLabelForCommand } from "../../keybindings";
import { persistClientSettingsUpdate } from "../../hooks/useSettings";
import {
type TerminalContextDraft,
type TerminalContextSelection,
Expand Down Expand Up @@ -198,6 +199,7 @@ import {
ComposerRestingImagePreviews,
} from "./ComposerAttachmentStrip";
import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
import { ComposerRichTextToggle } from "./ComposerRichTextToggle";
import { ComposerFooterPrimaryActions } from "./ComposerFooterControls";
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
Expand Down Expand Up @@ -304,6 +306,7 @@ export interface ChatComposerHandle {
openModelPicker: () => void;
stepReasoningEffort: (direction: 1 | -1) => void;
toggleModelPicker: () => void;
toggleRichText: () => void;
openControl: (command: KeybindingCommand) => void;
isModelPickerOpen: () => boolean;
isCaretAtStart: () => boolean;
Expand Down Expand Up @@ -2852,6 +2855,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
openModelPickerStable();
}
},
toggleRichText: () => {
if (isLiteralPendingAnswer) return;
persistClientSettingsUpdate((current) => ({
...current,
composerRichTextEnabled: !current.composerRichTextEnabled,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})).catch(console.error);
},
openControl: (command) => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
Expand Down Expand Up @@ -3429,7 +3439,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
showMobilePendingAnswerActions && "hidden sm:flex",
)}
>
{composerAttachButton ? <div className="me-2">{composerAttachButton}</div> : null}
{composerAttachButton || !isLiteralPendingAnswer ? (
<div className="me-2 flex items-center gap-0.5">
{composerAttachButton}
{isLiteralPendingAnswer ? null : (
<ComposerRichTextToggle
richTextEnabled={settings.composerRichTextEnabled}
shortcutLabel={shortcutLabelForCommand(
keybindings,
"composer.toggleRichText",
{
context: { terminalFocus: false, terminalOpen, modelPickerOpen: false },
},
)}
/>
)}
</div>
) : null}
<div
data-chat-composer-actions="right"
data-chat-composer-transition-actions="true"
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/chat/ComposerRichTextToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { FeatherIcon } from "lucide-react";

import { updateClientSettings } from "../../hooks/useSettings";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

// Same setting as Settings → Chat → Rich text composer. Flipping it remounts the
// editor; pointer-down is swallowed so a focused editor keeps its caret across that.
export function ComposerRichTextToggle({
richTextEnabled,
shortcutLabel,
}: {
readonly richTextEnabled: boolean;
readonly shortcutLabel: string | null;
}) {
const showingSource = !richTextEnabled;
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Markdown source"
aria-pressed={showingSource}
data-pressed={showingSource ? "" : undefined}
onPointerDown={(event) => event.preventDefault()}
onClick={() => void updateClientSettings({ composerRichTextEnabled: showingSource })}
/>
}
>
<FeatherIcon />
</TooltipTrigger>
<TooltipPopup>
{showingSource ? "Show formatting" : "Show Markdown source"}
{shortcutLabel ? ` · ${shortcutLabel}` : null}
</TooltipPopup>
</Tooltip>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/composerShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export function composerShortcutCommands(
"modelPicker.toggle": ({ event }) => {
if (!event.repeat) composerRef.current?.toggleModelPicker();
},
"composer.toggleRichText": ({ event }) => {
// The flip remounts the editor, which would drop an unsaved citation comment.
if (event.target instanceof Element && event.target.closest("[data-citation-comment-editor]"))
return false;
if (!event.repeat) composerRef.current?.toggleRichText();
},
"composer.increaseEffort": ({ event }) => {
if (!event.repeat) composerRef.current?.stepReasoningEffort(1);
},
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,12 +768,21 @@ describe("composer and pull request shortcuts", () => {
});

it.each(["MacIntel", "Win32", "Linux"])(
"reserves the alternate send chord for the focused composer on %s",
"resolves composer toggle and alternate send chords in their contexts on %s",
(platform) => {
const modifier = {
metaKey: platform === "MacIntel",
ctrlKey: platform !== "MacIntel",
};
for (const terminalFocus of [false, true]) {
assert.strictEqual(
resolveShortcutCommand(event({ key: "/", ...modifier }), DEFAULT_RESOLVED_KEYBINDINGS, {
platform,
context: { terminalFocus },
}),
terminalFocus ? null : "composer.toggleRichText",
);
}
const shiftedEnter = event({ key: "Enter", shiftKey: true, ...modifier });
for (const composerRequiresModifier of [false, true]) {
const context = {
Expand Down
4 changes: 3 additions & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ The composer writes Markdown and shows it styled as you type. Markers such as
them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists,
task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code
block. What you typed is what the agent receives, markers and numbering
included, and `#1234` without a space still looks up a pull request.
included, and `#1234` without a space still looks up a pull request. To see the
plain Markdown instead, press the button beside attach; it is the same
switch as **Settings → Chat → Rich text composer**.

Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter
on an empty line moves out one level, then leaves it; **Tab** nests a list item
Expand Down
1 change: 1 addition & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Use `mod+shift+e` for effort, `mod+shift+a` for permissions, `mod+shift+x` for t
workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
current checkout, a new worktree, and the previous worktree when available.
Use `mod+shift+l` to reuse the previous worktree directly.
Use `mod+/` to switch the composer between rich text and its Markdown source.

To change effort one step without opening the picker, bind
**Composer: Increase Effort** and **Composer: Decrease Effort** in Keybindings.
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
"appearance.cycle",
"themeEditor.toggle",
"composer.stash",
"composer.toggleRichText",
"composer.sendAlternate",
"composer.sendBackground",
"composer.sendAndNewThread",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" },
{ key: "mod+alt+shift+t", command: "themeEditor.toggle" },
{ key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
{ key: "mod+/", command: "composer.toggleRichText", when: "!terminalFocus" },
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
{ key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },
{ key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" },
{ key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" },
Expand Down
Loading