Skip to content

Commit ba946c0

Browse files
authored
Merge pull request #265 from mcode-webui/feat/webui-settings-shortcuts
feat(webui): let the settings page rebind the composer's send and newline keys
2 parents d959327 + 9daf9cf commit ba946c0

20 files changed

Lines changed: 1178 additions & 99 deletions

‎packages/webui/docs/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ is the sole decision record for Mini App hosting. ADR 0021 records the model pic
9090
with two states, and the cascade it replaces. ADR 0024 records the client-side workspace boundary
9191
for a diff body: a diff entry outside the workspace keeps its name and its counts and loses its
9292
content, the boundary is lexical, and a diff whose workspace root the client does not know is not
93-
withheld. A new decision takes the next free number.
93+
withheld. ADR 0026 records the composer's keyboard bindings as the settings page stores them: one
94+
canonical notation, the settings page owns the change, the composer reads the stored binding when
95+
a key is pressed, and only the actions that already have keyboard meaning are bindable. A new
96+
decision takes the next free number.
9497

9598
| Retired | Was | Why it is not in the log | Where the live facts are |
9699
| --- | --- | --- | --- |
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# The composer follows the bindings the settings page stores
2+
3+
**Status:** Accepted and implemented.
4+
**Date:** 2026-10-26.
5+
6+
The settings page's **快捷键** tab was a label with no panel behind it: `shortcuts` was the one
7+
preference entry declared `disabled: true` next to 语音 and 连接, and the composer decided what
8+
Enter meant in code — `resolveWebuiComposerEnterAction` answered "submit" or "newline" from the
9+
modifier flags alone. A user could not see that rule, could not change it, and a page that lists
10+
actions it cannot bind is a page that lies.
11+
12+
The settings page now **lists the composer's keyboard actions, shows the key each one is bound to,
13+
and changes the binding**. Two actions are bindable, because two actions have keyboard meaning
14+
today:
15+
16+
| Action | Built-in binding |
17+
| --- | --- |
18+
| 发送消息 — send the message | `Enter` |
19+
| 换行 — break the line | `Shift+Enter` |
20+
21+
## What a binding is
22+
23+
A binding is **one canonical string**: the modifiers in a fixed order, then the base key —
24+
`Control+Shift+Enter`. The same string is what the browser stores, what the page shows and what the
25+
composer matches against, so a binding cannot mean one thing on the page and another in the
26+
composer. The notation, the matching, the conflict rule and the fallback to the built-in bindings
27+
live in one module (`client/projection/common/shortcuts.ts`) that both sides read, because both sides
28+
have to agree on what a binding is.
29+
30+
The string is not a keymap. It is the smallest representation that lets a stored preference and a
31+
key press be compared without either side inventing its own vocabulary.
32+
33+
## Who owns the change, and who reads it
34+
35+
The **settings page owns the change**: it is the only surface that rebinds an action, and it hands
36+
the result to the settings owner, which persists it under `webui-shortcuts.v1` — the same habit as
37+
`webui-theme`, `mavis-locale` and `file_line_wrap`, and a key of its own so it cannot collide with
38+
the language preference.
39+
40+
The **composer reads it when a key is pressed**, not when it renders. The settings page and the
41+
composer are open at the same time, and a binding changed in the page has to be the binding the
42+
next key press obeys; a value read at press time is that, and a value held in component state would
43+
be whatever it was when the composer last rendered.
44+
45+
The composer therefore keeps no shortcut state of its own. What it owns is the rule: a press that
46+
names the send binding submits, a press that names the newline binding breaks the line, and a press
47+
that names neither is left to the browser — which is why a key the user unbound stops doing the
48+
composer's business and starts doing the textarea's.
49+
50+
## What the page refuses
51+
52+
- **A modifier on its own.** A binding is a combination; capturing `Shift` alone would leave a
53+
shortcut that fires whenever the user holds a key down.
54+
- **A combination another action already holds.** The page says which action holds it and keeps
55+
both bindings as they were.
56+
57+
## What this is not
58+
59+
- **Not a keybinding framework.** Two actions, one notation, one page. The voice, connection and
60+
personalization tabs stay disabled, and nothing here changes the server or the runtime.
61+
- **Not a new binding for stopping a generation.** 停止生成 is a button and nothing else, and the
62+
page says so. `Escape` is already spoken for by the menus it closes — the mention menu, the slash
63+
menu, the permission menu, goal mode and plan mode all claim it — so a shortcut for it would
64+
fight them rather than serve the user.
65+
- **Not the language pack.** This page's strings are hard-coded Chinese, like the rest of the
66+
settings page on this baseline. Where the settings page's language pack lands, these strings move
67+
with the others; the two changes touch the same tab table and nothing else.
68+
69+
## Consequences
70+
71+
- The composer no longer hard-codes what Enter means. `resolveWebuiComposerEnterAction` is retired:
72+
the rule it encoded is the default configuration, expressed as the same values the page shows.
73+
- The built-in behaviour is unchanged. With nothing stored, `Enter` sends and `Shift+Enter` breaks
74+
the line, and a press that names neither binding is left to the browser — which is what the
75+
retired rule did with every modified Enter.
76+
- A newline bound to a key the browser does not break the line on (`Control+J`, say) is broken by
77+
the composer itself, because a binding the user can set is a binding the user can rely on.

‎packages/webui/docs/client/composer.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ needs its own behavioural evidence — browser or assembled-application, per
1313
| Queue mirror | One selected destination with a generation, not a per-session mirror map; home display gates preserved | Refresh view interest without recreating the owner | Enqueue / delete / read commit only to an eligible destination generation; settlement obligations survive a selection change |
1414
| Retry | Last `{session, message}` retained, shown only for the matching session and refusal, never retargeted by adoption | Retained | Retry enters the ordinary send/enqueue workflow; goals and commands create no turn-retry record |
1515
| Mention and slash | Mention retained until an explicit command; slash derived from draft and caret | Recreate caret, index and listeners | Selection/dismiss controls the range; no universal switch or submit reset |
16+
| Send and newline keys | The bindable keys are the stored bindings, not a rule in the component; the settings page owns the change | The stored binding is read when a key is pressed, so a rebinding takes effect without a remount | 发送消息 submits and 换行 breaks the line; a press that names neither binding is left to the browser; an open IME composition belongs to the composition |
1617

1718
## Where the pieces live
1819

@@ -29,3 +30,10 @@ needs its own behavioural evidence — browser or assembled-application, per
2930
React-side store binding.
3031
- The submission path itself (creation, admission, send-or-queue) is
3132
[`submission.md`](submission.md).
33+
- The keyboard bindings live in the settings business: the page is
34+
`client/features/settings/ui/ShortcutsSettings.tsx` with its own model in
35+
`client/features/settings/projection/settings-shortcuts.ts`, and the notation both the page and the
36+
composer read is `client/projection/common/shortcuts.ts`. The composer keeps no binding state —
37+
it reads the stored binding when a key is pressed. ADR
38+
[`../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md`](../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md)
39+
records why.

‎packages/webui/src/client/features/composer/bindings/use-composer-input.ts‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react";
22
import { findWebuiMentionRange, removeWebuiSlashToken, type WebuiMentionRange, type WebuiSlashRange } from "../projection/composer-interactions.js";
3-
import { resolveWebuiComposerEnterAction } from "../projection/composer-state.js";
3+
import { resolveWebuiComposerShortcutAction } from "../projection/composer-state.js";
4+
import { parseShortcutBindings, shortcutBaseKey, SHORTCUT_STORAGE_KEY } from "../../../projection/common/shortcuts.js";
5+
import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js";
46
import { shouldRecallWebuiHistory, startWebuiHistoryBrowse, stepWebuiHistoryBrowse, type WebuiHistoryBrowse } from "../application/composer-history.js";
57

68
export interface ComposerInputKeyOptions {
@@ -59,6 +61,10 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
5961
const [mentionIndex, setMentionIndex] = useState(0);
6062
const [commandIndex, setCommandIndex] = useState(0);
6163
const [historyBrowse, setHistoryBrowse] = useState<WebuiHistoryBrowse>();
64+
// The bindings are read when a key is pressed rather than held in state: the
65+
// settings page can change them while the composer is open, and the next
66+
// press must follow the binding the user just set.
67+
const { storage } = useWebuiBrowserCapabilities();
6268

6369
useEffect(() => setHistoryBrowse(undefined), [sessionId]);
6470

@@ -85,6 +91,27 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
8591
input.setMentionRange(nextRange);
8692
}, []);
8793

94+
/**
95+
* Breaks the line at the caret.
96+
*
97+
* `Enter` breaks the line by itself, so this is only reached for a newline
98+
* bound to a key the browser does not break the line on.
99+
*/
100+
const insertNewline = useCallback((event: KeyboardEvent<HTMLTextAreaElement>, options: ComposerInputKeyOptions) => {
101+
const caret = event.currentTarget.selectionStart ?? options.draft.length;
102+
const value = `${options.draft.slice(0, caret)}\n${options.draft.slice(caret)}`;
103+
pendingCaret.current = caret + 1;
104+
edit({
105+
value,
106+
caret: caret + 1,
107+
changeDraft: options.onHistoryDraftChange ?? options.onDraftChange,
108+
mentionRange: options.mentionRange,
109+
setMentionRange: options.setMentionRange,
110+
setComposerMenu: options.setComposerMenu,
111+
setPermissionMenuOpen: options.setPermissionMenuOpen,
112+
});
113+
}, [edit]);
114+
88115
const updateCaret: ReturnType<typeof useComposerInput>["updateCaret"] = useCallback((value, nextCaret, _mentionRange, setMentionRange) => {
89116
setCaret(nextCaret);
90117
setMentionRange(findWebuiMentionRange(value, nextCaret));
@@ -145,20 +172,31 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
145172
return;
146173
}
147174
}
148-
if (event.key === "Enter" && options.commandCount === 0) {
149-
const action = resolveWebuiComposerEnterAction({
150-
shiftKey: event.shiftKey,
151-
altKey: event.altKey,
152-
ctrlKey: event.ctrlKey,
153-
metaKey: event.metaKey,
175+
if (options.commandCount === 0) {
176+
const bindings = parseShortcutBindings(storage.getItem(SHORTCUT_STORAGE_KEY));
177+
const action = resolveWebuiComposerShortcutAction({
178+
event: {
179+
key: event.key,
180+
code: event.code,
181+
ctrlKey: event.ctrlKey,
182+
metaKey: event.metaKey,
183+
altKey: event.altKey,
184+
shiftKey: event.shiftKey,
185+
},
154186
isComposing: event.nativeEvent.isComposing,
155187
submitBlocked: options.submitBlocked,
188+
bindings,
156189
});
157190
if (action === "submit") {
158191
event.preventDefault();
159192
event.currentTarget.form?.requestSubmit();
160193
return;
161194
}
195+
if (action === "newline" && shortcutBaseKey(bindings["composer.newline"]) !== "Enter") {
196+
event.preventDefault();
197+
insertNewline(event, options);
198+
return;
199+
}
162200
}
163201
if (options.commandCount === 0) return;
164202
if (event.key === "ArrowDown") {
@@ -171,7 +209,7 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
171209
event.preventDefault();
172210
options.chooseCommand(commandIndex);
173211
}
174-
}, [applyHistoryDraft, commandIndex, historyBrowse, mentionIndex]);
212+
}, [applyHistoryDraft, commandIndex, historyBrowse, insertNewline, mentionIndex, storage]);
175213

176214
return {
177215
textareaRef, caret, setCaret, mentionIndex, setMentionIndex, commandIndex, setCommandIndex,

‎packages/webui/src/client/features/composer/projection/composer-state.ts‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
WebuiSlashCommandFields,
44
} from "../contracts/slash-command.js";
55
import { classifyWebuiSlashCommand } from "../contracts/slash-command.js";
6+
import { shortcutMatches, type ShortcutBindings, type ShortcutKeyEvent } from "../../../projection/common/shortcuts.js";
67
export type WebuiSubmissionIntent =
78
| { readonly kind: "activate-goal-mode" }
89
| { readonly kind: "activate-plan-mode" }
@@ -55,19 +56,33 @@ export function resolveWebuiSubmissionIntent(args: {
5556
return { kind: "submit-turn" };
5657
}
5758

58-
export type WebuiComposerEnterAction = "submit" | "newline";
59+
export type WebuiComposerShortcutAction = "submit" | "newline" | "ignore";
5960

60-
export function resolveWebuiComposerEnterAction(args: {
61-
readonly shiftKey: boolean;
62-
readonly altKey: boolean;
63-
readonly ctrlKey: boolean;
64-
readonly metaKey: boolean;
61+
/**
62+
* What a key press in the composer does.
63+
*
64+
* The rule is the configured binding, not a hard-coded one: the settings page
65+
* can bind "send the message" to any combination, and the composer follows it.
66+
* `ignore` is the answer for a press that names no binding at all — the
67+
* browser's own behaviour then stands, which is how a key that is not bound
68+
* keeps doing what a key in a textarea does.
69+
*
70+
* The default bindings (`Enter` sends, `Shift+Enter` breaks the line) reproduce
71+
* the rule this composer had before the bindings were rebindable.
72+
*/
73+
export function resolveWebuiComposerShortcutAction(args: {
74+
readonly event: ShortcutKeyEvent;
6575
readonly isComposing: boolean;
6676
readonly submitBlocked: boolean;
67-
}): WebuiComposerEnterAction {
68-
if (args.shiftKey || args.altKey || args.ctrlKey || args.metaKey) return "newline";
69-
if (args.isComposing || args.submitBlocked) return "newline";
70-
return "submit";
77+
readonly bindings: ShortcutBindings;
78+
}): WebuiComposerShortcutAction {
79+
// While an input method is composing, Enter belongs to the composition.
80+
if (args.isComposing) return "ignore";
81+
if (shortcutMatches(args.bindings["composer.submit"], args.event)) {
82+
return args.submitBlocked ? "ignore" : "submit";
83+
}
84+
if (shortcutMatches(args.bindings["composer.newline"], args.event)) return "newline";
85+
return "ignore";
7186
}
7287

7388
export function deriveRecentWorkspaceDirs(
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// The shortcuts page's own model: which actions it lists, what each one is
2+
// called and which of them can be bound at all.
3+
//
4+
// The page renders this and the composer reads the bindings themselves, so the
5+
// names live here rather than in the component that draws the rows.
6+
7+
import {
8+
shortcutDisplayLabel,
9+
type ShortcutActionId,
10+
type ShortcutBindings,
11+
} from "../../../projection/common/shortcuts.js";
12+
13+
export interface ShortcutSettingsAction {
14+
readonly actionId: ShortcutActionId;
15+
/** The control's test id. It is a page detail, not a domain name. */
16+
readonly testId: string;
17+
readonly title: string;
18+
readonly description: string;
19+
/** Whether the row accepts a new binding. */
20+
readonly editable: boolean;
21+
/** What the row says for an action that has no key binding at all. */
22+
readonly note?: string;
23+
}
24+
25+
/**
26+
* The actions the page lists.
27+
*
28+
* Only the two composer actions that already have keyboard meaning are
29+
* bindable. Stopping a generation is a button and nothing else: `Escape` is
30+
* already spoken for by the menus it closes, so the row says so rather than
31+
* offering a binding that would fight them.
32+
*/
33+
export const SHORTCUT_SETTINGS_ACTIONS: readonly ShortcutSettingsAction[] = [
34+
{
35+
actionId: "composer.submit",
36+
testId: "mavis-settings-shortcut-submit",
37+
title: "发送消息",
38+
description: "在输入框中发送当前消息",
39+
editable: true,
40+
},
41+
{
42+
actionId: "composer.newline",
43+
testId: "mavis-settings-shortcut-newline",
44+
title: "换行",
45+
description: "在输入框中换行,不发送消息",
46+
editable: true,
47+
},
48+
{
49+
actionId: "composer.stop",
50+
testId: "mavis-settings-shortcut-stop",
51+
title: "停止生成",
52+
description: "仅按钮,无快捷键",
53+
editable: false,
54+
note: "仅按钮,无快捷键",
55+
},
56+
];
57+
58+
export interface ShortcutSettingsRow extends ShortcutSettingsAction {
59+
/** The action's current binding, in its canonical form. */
60+
readonly shortcut: string;
61+
/** How the current binding is shown. */
62+
readonly label: string;
63+
}
64+
65+
/** The page's rows, for a set of bindings. */
66+
export function shortcutSettingsRows(bindings: ShortcutBindings): readonly ShortcutSettingsRow[] {
67+
return SHORTCUT_SETTINGS_ACTIONS.map((action) => {
68+
const shortcut = bindings[action.actionId] ?? "";
69+
return {
70+
...action,
71+
shortcut,
72+
label: shortcut ? shortcutDisplayLabel(shortcut) : "未设置",
73+
};
74+
});
75+
}
76+
77+
/** The name of an action, for a message that names the action it collided with. */
78+
export function shortcutActionTitle(actionId: ShortcutActionId): string {
79+
return SHORTCUT_SETTINGS_ACTIONS.find((action) => action.actionId === actionId)?.title ?? actionId;
80+
}

‎packages/webui/src/client/features/settings/projection/settings-view.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instruc
1313
export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; }
1414

1515
export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [
16-
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
16+
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts" }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
1717
// The browser switches were under the browser panel, in the tool area. A
1818
// setting that lives in a panel is a setting nobody finds without opening
1919
// that panel first, so they are a page of their own — under 偏好, next to

0 commit comments

Comments
 (0)