diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go
index 00a11dece7..0be0329266 100644
--- a/apps/backend/internal/user/controller/controller.go
+++ b/apps/backend/internal/user/controller/controller.go
@@ -51,6 +51,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
PreferredShell: req.PreferredShell,
DefaultEditorID: req.DefaultEditorID,
EnablePreviewOnClick: req.EnablePreviewOnClick,
+ ChatSubmitKey: req.ChatSubmitKey,
})
if err != nil {
return dto.UserSettingsResponse{}, err
diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go
index f286afc219..c0d9c519e7 100644
--- a/apps/backend/internal/user/dto/dto.go
+++ b/apps/backend/internal/user/dto/dto.go
@@ -22,6 +22,7 @@ type UserSettingsDTO struct {
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
+ ChatSubmitKey string `json:"chat_submit_key"`
UpdatedAt string `json:"updated_at"`
}
@@ -48,6 +49,7 @@ type UpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
+ ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func FromUser(user *models.User) UserDTO {
@@ -69,6 +71,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
PreferredShell: settings.PreferredShell,
DefaultEditorID: settings.DefaultEditorID,
EnablePreviewOnClick: settings.EnablePreviewOnClick,
+ ChatSubmitKey: settings.ChatSubmitKey,
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
}
}
diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go
index a2dc45918b..bb156fe3cb 100644
--- a/apps/backend/internal/user/handlers/handlers.go
+++ b/apps/backend/internal/user/handlers/handlers.go
@@ -71,6 +71,7 @@ type httpUpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
+ ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
@@ -87,6 +88,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
PreferredShell: body.PreferredShell,
DefaultEditorID: body.DefaultEditorID,
EnablePreviewOnClick: body.EnablePreviewOnClick,
+ ChatSubmitKey: body.ChatSubmitKey,
})
if err != nil {
h.logger.Error("failed to update user settings", zap.Error(err))
@@ -112,6 +114,7 @@ type wsUpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
+ ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -127,6 +130,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*
PreferredShell: req.PreferredShell,
DefaultEditorID: req.DefaultEditorID,
EnablePreviewOnClick: req.EnablePreviewOnClick,
+ ChatSubmitKey: req.ChatSubmitKey,
})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil)
diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go
index fb71fb749c..783b07acda 100644
--- a/apps/backend/internal/user/models/models.go
+++ b/apps/backend/internal/user/models/models.go
@@ -18,6 +18,7 @@ type UserSettings struct {
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
+ ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go
index 6cd4428df3..e1d9b848ee 100644
--- a/apps/backend/internal/user/service/service.go
+++ b/apps/backend/internal/user/service/service.go
@@ -31,6 +31,7 @@ type UpdateUserSettingsRequest struct {
PreferredShell *string
DefaultEditorID *string
EnablePreviewOnClick *bool
+ ChatSubmitKey *string
}
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
@@ -92,6 +93,14 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
if req.EnablePreviewOnClick != nil {
settings.EnablePreviewOnClick = *req.EnablePreviewOnClick
}
+ if req.ChatSubmitKey != nil {
+ key := strings.TrimSpace(*req.ChatSubmitKey)
+ if key != "enter" && key != "cmd_enter" {
+ return nil, errors.New("chat_submit_key must be 'enter' or 'cmd_enter'")
+ }
+ s.logger.Info("[Settings] Setting ChatSubmitKey", zap.String("value", key))
+ settings.ChatSubmitKey = key
+ }
settings.UpdatedAt = time.Now().UTC()
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
return nil, err
@@ -113,6 +122,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
"enable_preview_on_click": settings.EnablePreviewOnClick,
+ "chat_submit_key": settings.ChatSubmitKey,
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
}
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go
index b5295c4640..53cba4d2cd 100644
--- a/apps/backend/internal/user/store/sqlite.go
+++ b/apps/backend/internal/user/store/sqlite.go
@@ -127,6 +127,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
"enable_preview_on_click": settings.EnablePreviewOnClick,
+ "chat_submit_key": settings.ChatSubmitKey,
})
if err != nil {
return err
@@ -172,6 +173,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
+ ChatSubmitKey string `json:"chat_submit_key"`
}
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
return nil, err
@@ -183,5 +185,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
settings.PreferredShell = payload.PreferredShell
settings.DefaultEditorID = payload.DefaultEditorID
settings.EnablePreviewOnClick = payload.EnablePreviewOnClick
+ settings.ChatSubmitKey = payload.ChatSubmitKey
+ // Default to "cmd_enter" if empty
+ if settings.ChatSubmitKey == "" {
+ settings.ChatSubmitKey = "cmd_enter"
+ }
return settings, nil
}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 0965606220..968743b1c1 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -58,6 +58,7 @@ export default async function Page({ searchParams }: PageProps) {
shellOptions: userSettingsResponse?.shell_options ?? [],
defaultEditorId: userSettings?.default_editor_id || null,
enablePreviewOnClick: settingsEnablePreviewOnClick,
+ chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
loaded: Boolean(userSettings),
},
};
diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx
index 5e5b1b5020..e9fef3daf2 100644
--- a/apps/web/app/s/[sessionId]/page.tsx
+++ b/apps/web/app/s/[sessionId]/page.tsx
@@ -4,6 +4,7 @@ import {
fetchBoardSnapshot,
fetchTaskSession,
fetchTask,
+ fetchUserSettings,
listAgents,
listAvailableAgents,
listBoards,
@@ -38,7 +39,7 @@ export default async function SessionPage({
}
task = await fetchTask(session.task_id, { cache: 'no-store' });
- const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse] = await Promise.all([
+ const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse, userSettingsResponse] = await Promise.all([
fetchBoardSnapshot(task.board_id, { cache: 'no-store' }),
listAgents({ cache: 'no-store' }),
listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }),
@@ -47,6 +48,7 @@ export default async function SessionPage({
listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })),
listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })),
listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })),
+ fetchUserSettings({ cache: 'no-store' }).catch(() => null),
]);
const repositories = repositoriesResponse.repositories ?? [];
const allSessions = allSessionsResponse.sessions ?? [session];
@@ -54,6 +56,7 @@ export default async function SessionPage({
const workspaces = workspacesResponse.workspaces ?? [];
const boards = boardsResponse.boards ?? [];
const turns = turnsResponse.turns ?? [];
+ const userSettings = userSettingsResponse?.settings;
// Get repository scripts from the repository (already fetched with includeScripts)
const repositoryId = task.repositories?.[0]?.repository_id;
@@ -203,6 +206,17 @@ export default async function SessionPage({
loaded: true,
loading: false,
},
+ userSettings: {
+ workspaceId: userSettings?.workspace_id || null,
+ boardId: userSettings?.board_id || null,
+ repositoryIds: Array.from(new Set(userSettings?.repository_ids ?? [])).sort(),
+ preferredShell: userSettings?.preferred_shell || null,
+ shellOptions: userSettingsResponse?.shell_options ?? [],
+ defaultEditorId: userSettings?.default_editor_id || null,
+ enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false,
+ chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
+ loaded: Boolean(userSettings),
+ },
};
} catch (error) {
console.warn(
diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx
index 949371d188..e614baf132 100644
--- a/apps/web/app/settings/general/page.tsx
+++ b/apps/web/app/settings/general/page.tsx
@@ -16,6 +16,8 @@ export default async function GeneralSettingsPage() {
preferredShell: settings.preferred_shell ?? null,
shellOptions: response.shell_options ?? [],
defaultEditorId: settings.default_editor_id ?? null,
+ enablePreviewOnClick: settings.enable_preview_on_click ?? false,
+ chatSubmitKey: settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
}
: undefined,
diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx
index ebef23320c..0d99549a39 100644
--- a/apps/web/components/settings/editors-settings.tsx
+++ b/apps/web/components/settings/editors-settings.tsx
@@ -370,6 +370,7 @@ export function EditorsSettings() {
shellOptions: currentUserSettings.shellOptions ?? [],
defaultEditorId: response.settings.default_editor_id || null,
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
+ chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
}
diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx
index 7205d45b99..13f55c0f2a 100644
--- a/apps/web/components/settings/general-settings.tsx
+++ b/apps/web/components/settings/general-settings.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useTheme } from 'next-themes';
-import { IconPalette, IconServer } from '@tabler/icons-react';
+import { IconPalette, IconServer, IconKeyboard } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Label } from '@kandev/ui/label';
import { Input } from '@kandev/ui/input';
@@ -11,6 +11,8 @@ import { Separator } from '@kandev/ui/separator';
import { SettingsSection } from '@/components/settings/settings-section';
import { ShellSettingsCard } from '@/components/settings/shell-settings-card';
import { getBackendConfig } from '@/lib/config';
+import { useAppStore } from '@/components/state-provider';
+import { updateUserSettings } from '@/lib/api';
import type { Theme } from '@/lib/settings/types';
export function GeneralSettings() {
@@ -19,6 +21,39 @@ export function GeneralSettings() {
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
const themeValue = currentTheme ?? 'system';
+ // User settings
+ const userSettings = useAppStore((state) => state.userSettings);
+ const setUserSettings = useAppStore((state) => state.setUserSettings);
+ const [isSavingSubmitKey, setIsSavingSubmitKey] = useState(false);
+
+ const handleChatSubmitKeyChange = async (value: 'enter' | 'cmd_enter') => {
+ if (isSavingSubmitKey) return;
+
+ setIsSavingSubmitKey(true);
+ const previousValue = userSettings.chatSubmitKey;
+
+ try {
+ setUserSettings({
+ ...userSettings,
+ chatSubmitKey: value,
+ });
+
+ await updateUserSettings({
+ workspace_id: userSettings.workspaceId || '',
+ board_id: userSettings.boardId || '',
+ repository_ids: userSettings.repositoryIds || [],
+ chat_submit_key: value,
+ });
+ } catch {
+ setUserSettings({
+ ...userSettings,
+ chatSubmitKey: previousValue,
+ });
+ } finally {
+ setIsSavingSubmitKey(false);
+ }
+ };
+
return (
@@ -62,6 +97,43 @@ export function GeneralSettings() {
+
}
+ title="Chat Input"
+ description="Configure chat input behavior"
+ >
+
+
+ Submit Shortcut
+
+
+
+
Message Submit Key
+
handleChatSubmitKeyChange(value as 'enter' | 'cmd_enter')}
+ disabled={isSavingSubmitKey}
+ >
+
+
+
+
+ Cmd/Ctrl+Enter to send
+ Enter to send
+
+
+
+ {userSettings.chatSubmitKey === 'cmd_enter'
+ ? 'Press Cmd/Ctrl+Enter to send messages. Press Enter for newlines.'
+ : 'Press Enter to send messages. Press Shift+Enter for newlines.'}
+
+
+
+
+
+
+
+
}
title="Backend Connection"
diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx
index 8bf7871cf2..a7730d62eb 100644
--- a/apps/web/components/task/chat/chat-input-container.tsx
+++ b/apps/web/components/task/chat/chat-input-container.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useRef, useCallback, useState, useEffect, memo, type KeyboardEvent as ReactKeyboardEvent } from 'react';
+import { useRef, useCallback, useState, useEffect, memo, forwardRef, useImperativeHandle, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import {
IconArrowUp,
IconBrain,
@@ -27,6 +27,7 @@ import { MentionMenu } from './mention-menu';
import { SlashCommandMenu } from './slash-command-menu';
import { ClarificationInputOverlay } from './clarification-input-overlay';
import { CommentBlocksContainer } from './comment-block';
+import { ChatInputFocusHint } from './chat-input-focus-hint';
import { useInlineMention } from '@/hooks/use-inline-mention';
import { useInlineSlash } from '@/hooks/use-inline-slash';
import type { Message } from '@/lib/types/http';
@@ -45,6 +46,7 @@ type ChatInputToolbarProps = {
isSending: boolean;
onCancel: () => void;
onSubmit: () => void;
+ submitKey?: 'enter' | 'cmd_enter';
};
const ChatInputToolbar = memo(function ChatInputToolbar({
@@ -59,7 +61,10 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
isSending,
onCancel,
onSubmit,
+ submitKey = 'cmd_enter',
}: ChatInputToolbarProps) {
+ const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
+
return (
{/* Left: Plan, Thinking, Context */}
@@ -129,7 +134,7 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
{/* Submit/Stop button */}
-
+
{isAgentBusy ? (
void;
+ getTextareaElement: () => HTMLTextAreaElement | null;
+ getValue: () => string;
+ getSelectionStart: () => number;
+ insertText: (text: string, from: number, to: number) => void;
+};
+
type ChatInputContainerProps = {
onSubmit: (message: string, reviewComments?: DiffComment[]) => void;
sessionId: string | null;
@@ -189,34 +202,60 @@ type ChatInputContainerProps = {
onRemoveComment?: (sessionId: string, filePath: string, commentId: string) => void;
/** Callback when a comment is clicked (for jump-to-line) */
onCommentClick?: (comment: DiffComment) => void;
+ /** Chat submit key preference */
+ submitKey?: 'enter' | 'cmd_enter';
+ /** Whether the current agent has commands (affects placeholder) */
+ hasAgentCommands?: boolean;
};
-export function ChatInputContainer({
- onSubmit,
- sessionId,
- taskId,
- taskTitle,
- taskDescription,
- planModeEnabled,
- onPlanModeChange,
- isAgentBusy,
- isStarting,
- isSending,
- onCancel,
- placeholder,
- pendingClarification,
- onClarificationResolved,
- showRequestChangesTooltip = false,
- onRequestChangesTooltipDismiss,
- pendingCommentsByFile,
- onRemoveCommentFile,
- onRemoveComment,
- onCommentClick,
-}: ChatInputContainerProps) {
+export const ChatInputContainer = forwardRef(
+ function ChatInputContainer(
+ {
+ onSubmit,
+ sessionId,
+ taskId,
+ taskTitle,
+ taskDescription,
+ planModeEnabled,
+ onPlanModeChange,
+ isAgentBusy,
+ isStarting,
+ isSending,
+ onCancel,
+ placeholder,
+ pendingClarification,
+ onClarificationResolved,
+ showRequestChangesTooltip = false,
+ onRequestChangesTooltipDismiss,
+ pendingCommentsByFile,
+ onRemoveCommentFile,
+ onRemoveComment,
+ onCommentClick,
+ submitKey = 'cmd_enter',
+ hasAgentCommands = false,
+ },
+ ref
+ ) {
// Keep input state local to avoid re-rendering parent on every keystroke
const [value, setValue] = useState('');
+ const [isInputFocused, setIsInputFocused] = useState(false);
const inputRef = useRef(null);
+ // Expose imperative handle for parent
+ useImperativeHandle(
+ ref,
+ () => ({
+ focusInput: () => inputRef.current?.focus(),
+ getTextareaElement: () => inputRef.current?.getTextareaElement() ?? null,
+ getValue: () => inputRef.current?.getValue() ?? '',
+ getSelectionStart: () => inputRef.current?.getSelectionStart() ?? 0,
+ insertText: (text: string, from: number, to: number) => {
+ inputRef.current?.insertText(text, from, to);
+ },
+ }),
+ []
+ );
+
// Focus input when request changes tooltip is shown
useEffect(() => {
if (showRequestChangesTooltip && inputRef.current) {
@@ -315,16 +354,28 @@ export function ChatInputContainer({
const hasPendingClarification = pendingClarification && onClarificationResolved;
const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0;
+ // Dynamic placeholder
+ const inputPlaceholder =
+ placeholder ||
+ (hasAgentCommands
+ ? 'Ask to make changes, @mention files, run /commands'
+ : 'Ask to make changes, @mention files');
+
+ // Show focus hint when input not focused, no clarification, and no comments
+ const showFocusHint = !isInputFocused && !hasPendingClarification && !hasPendingComments;
+
return (
+ {/* Focus hint */}
+
{/* Popup menus */}
setIsInputFocused(true)}
+ onBlur={() => setIsInputFocused(false)}
/>
@@ -404,7 +458,8 @@ export function ChatInputContainer({
isSending={isSending}
onCancel={onCancel}
onSubmit={handleSubmit}
+ submitKey={submitKey}
/>
);
-}
+});
diff --git a/apps/web/components/task/chat/chat-input-focus-hint.tsx b/apps/web/components/task/chat/chat-input-focus-hint.tsx
new file mode 100644
index 0000000000..52045014fd
--- /dev/null
+++ b/apps/web/components/task/chat/chat-input-focus-hint.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+type ChatInputFocusHintProps = {
+ visible: boolean;
+ className?: string;
+};
+
+export function ChatInputFocusHint({ visible, className }: ChatInputFocusHintProps) {
+ if (!visible) return null;
+
+ return (
+
+
+ /
+
+ to focus
+
+ );
+}
diff --git a/apps/web/components/task/chat/rich-text-input.tsx b/apps/web/components/task/chat/rich-text-input.tsx
index fe98a91893..78f28f67f4 100644
--- a/apps/web/components/task/chat/rich-text-input.tsx
+++ b/apps/web/components/task/chat/rich-text-input.tsx
@@ -21,6 +21,7 @@ export type RichTextInputHandle = {
getValue: () => string;
setValue: (value: string) => void;
insertText: (text: string, from: number, to: number) => void;
+ getTextareaElement: () => HTMLTextAreaElement | null;
};
type RichTextInputProps = {
@@ -32,6 +33,9 @@ type RichTextInputProps = {
disabled?: boolean;
className?: string;
planModeEnabled?: boolean;
+ submitKey?: 'enter' | 'cmd_enter';
+ onFocus?: () => void;
+ onBlur?: () => void;
};
export const RichTextInput = forwardRef
(
@@ -45,6 +49,9 @@ export const RichTextInput = forwardRef
disabled = false,
className,
planModeEnabled = false,
+ submitKey = 'cmd_enter',
+ onFocus,
+ onBlur,
},
ref
) {
@@ -164,6 +171,7 @@ export const RichTextInput = forwardRef
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
});
},
+ getTextareaElement: () => textareaRef.current,
}),
[getCaretRect, onChange, value]
);
@@ -179,11 +187,21 @@ export const RichTextInput = forwardRef
if (event.defaultPrevented) return;
}
- // Submit only on Cmd/Ctrl+Enter, and only if not disabled
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
- event.preventDefault();
- if (!disabled) {
- onSubmit?.();
+ if (submitKey === 'enter') {
+ // Submit on Enter (unless Shift for newline)
+ if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
+ event.preventDefault();
+ if (!disabled) {
+ onSubmit?.();
+ }
+ }
+ } else {
+ // Submit on Cmd/Ctrl+Enter (current behavior)
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ if (!disabled) {
+ onSubmit?.();
+ }
}
}
};
@@ -194,6 +212,8 @@ export const RichTextInput = forwardRef
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
+ onFocus={onFocus}
+ onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
className={cn(
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index cc8f017a20..3a69be583f 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState, memo } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore } from '@/components/state-provider';
import { getLocalStorage } from '@/lib/local-storage';
+import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
+import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
import { useSessionState } from '@/hooks/domains/session/use-session-state';
@@ -12,7 +14,7 @@ import { useSessionModel } from '@/hooks/domains/session/use-session-model';
import { useMessageHandler } from '@/hooks/use-message-handler';
import { TodoSummary } from '@/components/task/chat/todo-summary';
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
-import { ChatInputContainer } from '@/components/task/chat/chat-input-container';
+import { ChatInputContainer, type ChatInputContainerHandle } from '@/components/task/chat/chat-input-container';
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
import {
useDiffCommentsStore,
@@ -40,6 +42,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}: TaskChatPanelProps) {
const [isSending, setIsSending] = useState(false);
const lastAgentMessageCountRef = useRef(0);
+ const chatInputRef = useRef(null);
// Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths)
useSettingsData(true);
@@ -104,6 +107,13 @@ export const TaskChatPanel = memo(function TaskChatPanel({
session?.agent_profile_id
);
+ // User settings
+ const chatSubmitKey = useAppStore((state) => state.userSettings.chatSubmitKey);
+ const agentCommands = useAppStore((state) =>
+ resolvedSessionId ? state.availableCommands.bySessionId[resolvedSessionId] : undefined
+ );
+ const hasAgentCommands = agentCommands && agentCommands.length > 0;
+
// Message sending
const { handleSendMessage } = useMessageHandler(
resolvedSessionId,
@@ -183,6 +193,42 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}
}, [isSending, onSend, handleSendMessage, markCommentsSent]);
+ // Focus input with / shortcut (only when input is NOT focused)
+ useKeyboardShortcut(
+ SHORTCUTS.FOCUS_INPUT,
+ useCallback((event: KeyboardEvent) => {
+ // Only handle if we're not already in an input/textarea
+ const activeElement = document.activeElement;
+ const isTyping =
+ activeElement instanceof HTMLInputElement ||
+ activeElement instanceof HTMLTextAreaElement;
+
+ if (isTyping) {
+ // Don't intercept - let the "/" be typed normally for slash commands
+ return;
+ }
+
+ // Not typing anywhere, so focus the chat input
+ const inputHandle = chatInputRef.current;
+ if (inputHandle) {
+ event.preventDefault(); // Prevent the "/" from being typed when we focus
+ inputHandle.focusInput();
+ }
+ }, []),
+ { enabled: true, preventDefault: false }
+ );
+
+ // Cancel agent turn with ESC shortcut
+ useKeyboardShortcut(
+ SHORTCUTS.CANCEL,
+ useCallback(() => {
+ if (isAgentBusy) {
+ handleCancelTurn();
+ }
+ }, [isAgentBusy, handleCancelTurn]),
+ { enabled: isAgentBusy }
+ );
+
return (
@@ -207,6 +253,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
{todoItems.length > 0 && }
onOpenFileAtLine(comment.filePath) : undefined}
+ submitKey={chatSubmitKey}
+ hasAgentCommands={hasAgentCommands}
/>
diff --git a/apps/web/hooks/domains/settings/use-editors.ts b/apps/web/hooks/domains/settings/use-editors.ts
index dd3881385a..978b0cd362 100644
--- a/apps/web/hooks/domains/settings/use-editors.ts
+++ b/apps/web/hooks/domains/settings/use-editors.ts
@@ -52,6 +52,7 @@ export function useEditors() {
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
+ chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
})
diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts
index c9c836d76c..3ca3db3d50 100644
--- a/apps/web/hooks/use-user-display-settings.ts
+++ b/apps/web/hooks/use-user-display-settings.ts
@@ -14,6 +14,7 @@ type DisplaySettings = {
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
+ chatSubmitKey: 'enter' | 'cmd_enter';
loaded: boolean;
};
@@ -56,6 +57,7 @@ export function useUserDisplaySettings({
shellOptions: userSettings.shellOptions ?? [],
defaultEditorId: userSettings.defaultEditorId ?? null,
enablePreviewOnClick,
+ chatSubmitKey: userSettings.chatSubmitKey ?? 'cmd_enter',
loaded: true,
};
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
@@ -105,6 +107,7 @@ export function useUserDisplaySettings({
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
+ chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
})
diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts
index 4fa20f2fe2..cd323c7e8d 100644
--- a/apps/web/lib/api/domains/settings-api.ts
+++ b/apps/web/lib/api/domains/settings-api.ts
@@ -23,12 +23,13 @@ export async function fetchUserSettings(options?: ApiRequestOptions) {
export async function updateUserSettings(
payload: {
- workspace_id: string;
- board_id: string;
- repository_ids: string[];
+ workspace_id?: string;
+ board_id?: string;
+ repository_ids?: string[];
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
+ chat_submit_key?: 'enter' | 'cmd_enter';
},
options?: ApiRequestOptions
) {
diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts
index 4ce96d9b4f..a3d219c417 100644
--- a/apps/web/lib/keyboard/constants.ts
+++ b/apps/web/lib/keyboard/constants.ts
@@ -60,6 +60,8 @@ export const KEYS = {
SEVEN: '7',
EIGHT: '8',
NINE: '9',
+ // Symbols
+ SLASH: '/',
} as const;
// Platform types
@@ -94,6 +96,9 @@ export const SHORTCUTS = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
},
+ SUBMIT_ENTER: {
+ key: KEYS.ENTER,
+ },
SAVE: {
key: KEYS.S,
modifiers: { ctrlOrCmd: true },
@@ -113,5 +118,8 @@ export const SHORTCUTS = {
key: KEYS.B,
modifiers: { ctrlOrCmd: true },
},
+ FOCUS_INPUT: {
+ key: KEYS.SLASH,
+ },
} as const;
diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts
index 147d36c609..27581320f3 100644
--- a/apps/web/lib/state/slices/settings/settings-slice.ts
+++ b/apps/web/lib/state/slices/settings/settings-slice.ts
@@ -26,6 +26,7 @@ export const defaultSettingsState: SettingsSliceState = {
shellOptions: [],
defaultEditorId: null,
enablePreviewOnClick: false,
+ chatSubmitKey: 'cmd_enter',
loaded: false,
},
};
diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts
index bab7f1410c..51a5bff445 100644
--- a/apps/web/lib/state/slices/settings/types.ts
+++ b/apps/web/lib/state/slices/settings/types.ts
@@ -76,6 +76,7 @@ export type UserSettingsState = {
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
+ chatSubmitKey: 'enter' | 'cmd_enter';
loaded: boolean;
};
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 8d7486caf9..ef6e8e49d7 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -282,6 +282,7 @@ export type UserSettingsUpdatedPayload = {
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
+ chat_submit_key?: string;
updated_at?: string;
};
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 69bd374bed..465326191a 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -316,6 +316,7 @@ export type UserSettings = {
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
+ chat_submit_key?: 'enter' | 'cmd_enter';
updated_at: string;
};
diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts
index 579425f727..2cfb6e62cd 100644
--- a/apps/web/lib/ws/handlers/users.ts
+++ b/apps/web/lib/ws/handlers/users.ts
@@ -16,6 +16,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers {
shellOptions: state.userSettings.shellOptions ?? [],
defaultEditorId: message.payload.default_editor_id || null,
enablePreviewOnClick: message.payload.enable_preview_on_click ?? false,
+ chatSubmitKey: (message.payload.chat_submit_key as 'enter' | 'cmd_enter') ?? 'cmd_enter',
loaded: true,
},
}));