dsh-client-ui-enter-newline 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +406 -0
- package/lib/index.js +17 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-client-ui-enter-newline contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# dsh-client-ui-enter-newline
|
|
2
|
+
|
|
3
|
+
A DSH Web client plugin. It adds one checkbox — **Enter 键仅换行** — to the left of the
|
|
4
|
+
model / reasoning-effort selector in the composer toolbar:
|
|
5
|
+
|
|
6
|
+
| Checkbox | Plain `Enter` | `Shift`+`Enter` | `Ctrl`/`Cmd`+`Enter` |
|
|
7
|
+
|---|---|---|---|
|
|
8
|
+
| unchecked (default) | sends, exactly as shipped | line break | shipped accelerated send |
|
|
9
|
+
| checked | line break, **submit is suppressed** | line break | shipped accelerated send |
|
|
10
|
+
|
|
11
|
+
The preference is stored in the browser under `localStorage['dsh.enterNewlineOnly']`
|
|
12
|
+
(`"1"` = checked). A missing or unreadable key means unchecked, so the plugin's
|
|
13
|
+
default state is the untouched DSH behavior.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
| File | Role |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `lib/index.js` | Host half: an empty Cordis entry, so the loader has something to mount. |
|
|
20
|
+
| `lib/client.js` | Browser half: the checkbox component, the preference store, and the Enter guard. |
|
|
21
|
+
|
|
22
|
+
## How it hooks in
|
|
23
|
+
|
|
24
|
+
* **The checkbox** is registered into the shipped `conversation.input.left` slot —
|
|
25
|
+
the composer toolbar row's leading group. That group renders as a sibling
|
|
26
|
+
immediately before the trailing group that holds `conversation.input.right` and the
|
|
27
|
+
model selector, so the control lands directly on the selector's left with the
|
|
28
|
+
toolbar's own flex spacing. The plugin adds one inline cell; no shipped markup is
|
|
29
|
+
replaced, reordered, or restyled. The slot is session-scoped and is never hidden by
|
|
30
|
+
the toolbar's activity expansion, so the control lives exactly as long as the
|
|
31
|
+
resident composer does.
|
|
32
|
+
* **The Enter guard** is a `keydown` listener in the capture phase, registered on
|
|
33
|
+
`window` and on `document`. The composer's Enter command lives inside Lexical, which
|
|
34
|
+
listens on the editor root in the bubble phase, so a capturing listener runs strictly
|
|
35
|
+
earlier. When the checkbox is on and the event is a plain Enter inside the composer's
|
|
36
|
+
draft host, the listener stops propagation — Lexical never sees the key, and because
|
|
37
|
+
the event is *not* `preventDefault()`ed the browser still inserts the line break,
|
|
38
|
+
which is exactly what `Shift`+`Enter` does. Every other key, and every Enter while
|
|
39
|
+
the checkbox is off, is untouched.
|
|
40
|
+
* **The draft host is detected from the live node**, not a fixed attribute value: any
|
|
41
|
+
element whose `isContentEditable` is true (which also covers
|
|
42
|
+
`contenteditable="plaintext-only"`) or a `textarea`, as long as it sits inside a
|
|
43
|
+
`[data-composer-card]`. A kernel release that changes how the draft is hosted
|
|
44
|
+
therefore cannot silently disable the guard, and no editor outside the composer is
|
|
45
|
+
ever touched.
|
|
46
|
+
* **The preference the guard acts on is read from the rendered control itself**
|
|
47
|
+
(`checked` on the plugin's own checkbox), not from an in-memory copy, with the
|
|
48
|
+
page-global record as the fallback for moments when the control is unmounted. The
|
|
49
|
+
control's `checked` property is the value the user is looking at, so nothing that can
|
|
50
|
+
desynchronize memory — a hot reload leaving an older bundle execution's listener
|
|
51
|
+
installed, or a storage write that never landed — can make the guard disagree with
|
|
52
|
+
the checkbox on screen. Two historical bugs came from getting this wrong: a
|
|
53
|
+
module-local flag that went stale across reloads, and a seat check that also excluded
|
|
54
|
+
anything inside a composer card *containing* the seat (which is true of the composer's
|
|
55
|
+
own editor, so the guard skipped exactly the events it exists for). The regression
|
|
56
|
+
tests in `test/guard.test.mjs` cover both.
|
|
57
|
+
|
|
58
|
+
`Shift`/`Ctrl`/`Meta`/`Alt`/`AltGr` chords and IME composition are never intercepted.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
The package declares `dsh.bundle.patch`, so it installs as an ordinary DSH bundle and
|
|
63
|
+
registers its own loader entry — no hand-edited YAML, no build step on the user's
|
|
64
|
+
machine:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
dsh plugin --profile <profile> add dsh-client-ui-enter-newline # from npm
|
|
68
|
+
dsh plugin --profile <profile> add ./dsh-client-ui-enter-newline-1.0.0.tgz # from a tarball
|
|
69
|
+
dsh plugin --profile <profile> add github:<you>/<repo> # from a git host
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`add` appends the bundle to `dsh.profile.bundles`; `dsh plugin --profile <profile>
|
|
73
|
+
remove dsh-client-ui-enter-newline` takes it back out. Check the composed tree before
|
|
74
|
+
booting with `dsh --profile <profile> --dump-config`.
|
|
75
|
+
|
|
76
|
+
Installing by hand is equally fine: put the package where the profile's module
|
|
77
|
+
resolution can see it and add the same row the bundle layer adds —
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# <DSH_HOME>/profiles/<profile>/cordis.patch.yml
|
|
81
|
+
- insert:
|
|
82
|
+
- id: enter-newline
|
|
83
|
+
name: 'dsh-client-ui-enter-newline'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A profile that watches its patch file picks the entry up live: the host side becomes an
|
|
87
|
+
active loader entry and the browser half mounts without restarting DSH. A profile that
|
|
88
|
+
does not hot-reload its patch layer needs a restart.
|
|
89
|
+
|
|
90
|
+
## Distribute
|
|
91
|
+
|
|
92
|
+
Publish the **built** package — the bundle is plain JavaScript, so there is nothing to
|
|
93
|
+
compile, and `lib/` ships as-is. Three routes, in order of how little the user has to do:
|
|
94
|
+
|
|
95
|
+
| Route | User command | Build on install |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| npm | `dsh plugin add dsh-client-ui-enter-newline` | none |
|
|
98
|
+
| tarball (`pnpm pack`) | `dsh plugin add ./dsh-client-ui-enter-newline-1.0.0.tgz` | none |
|
|
99
|
+
| git host | `dsh plugin add github:you/repo` | `prepare` must build `lib/`, and the user must allow it |
|
|
100
|
+
|
|
101
|
+
Prefer npm or a tarball: pnpm ≥10 refuses to run a git dependency's `prepare` script
|
|
102
|
+
until the user explicitly allows it in the profile's `pnpm-workspace.yaml`
|
|
103
|
+
(`allowBuilds:`), and that permission is "let this package run code on my machine at
|
|
104
|
+
install time". Distributing built artifacts keeps that decision off the user's plate.
|
|
105
|
+
|
|
106
|
+
Before publishing, the five fields that decide whether an install *works* rather than
|
|
107
|
+
merely *succeeds*:
|
|
108
|
+
|
|
109
|
+
1. `main` / `exports` point at the built entry (`lib/index.js`), never at sources;
|
|
110
|
+
2. `"type": "module"`;
|
|
111
|
+
3. `files` includes the entry **and** `cordis.patch.yml` — the patch is the bundle layer,
|
|
112
|
+
so omitting it yields a package that installs but activates nothing;
|
|
113
|
+
4. `dsh.bundle.patch` points at that patch file — without it the package is only a plain
|
|
114
|
+
dependency and `dsh plugin` warns that no layer was activated;
|
|
115
|
+
5. `version` bumped per semver — a published version can never be overwritten.
|
|
116
|
+
|
|
117
|
+
Then:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pnpm pack --dry-run # confirm lib/ + cordis.patch.yml are inside the tarball
|
|
121
|
+
pnpm publish # scoped packages: publishConfig.access = public
|
|
122
|
+
dsh plugin --profile fresh add ./dsh-client-ui-enter-newline-1.0.0.tgz
|
|
123
|
+
dsh --profile fresh --dump-config # expect a "# == dsh-client-ui-enter-newline" layer
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Verify in a **fresh** profile: an install tested in your own profile can pass on
|
|
127
|
+
dependencies that happen to be there already.
|
|
128
|
+
|
|
129
|
+
## Verify
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
node test/guard.test.mjs lib/client.js
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The check loads the real bundle against a minimal DOM stub and asserts the shipped
|
|
136
|
+
behavior, the suppressed behavior, every un-intercepted chord, IME composition,
|
|
137
|
+
out-of-composer targets, `plaintext-only` and detached draft hosts, and the checked
|
|
138
|
+
styling on mount. It deliberately cannot prove anything about the live Lexical editor
|
|
139
|
+
or the shipped markup — open the composer and press `Enter` for that.
|
|
140
|
+
|
|
141
|
+
Two live probes confirm the wiring without touching the page:
|
|
142
|
+
|
|
143
|
+
* `cordis_inspect_query` → host `Config` → `listConfigs` with
|
|
144
|
+
`{"name": "dsh-client-ui-enter-newline"}` returns the loader entry.
|
|
145
|
+
* `cordis_inspect_query` → client `Slots` → `listSubTree` with
|
|
146
|
+
`{"root": "conversation.input.left"}` lists `enter-newline-toggle` as an active
|
|
147
|
+
occupant.
|
|
148
|
+
|
|
149
|
+
## Uninstall
|
|
150
|
+
|
|
151
|
+
Delete the loader entry above, then the installed package directory. The stored
|
|
152
|
+
preference key is inert on its own and can be removed with
|
|
153
|
+
`localStorage.removeItem('dsh.enterNewlineOnly')`.
|
|
154
|
+
|
|
155
|
+
## Limitations
|
|
156
|
+
|
|
157
|
+
* The guard targets the composer's plain `Enter`. The primary Send button is a
|
|
158
|
+
separate control and still sends while the checkbox is on — by design: the
|
|
159
|
+
preference is about the keyboard, and a button is an explicit click.
|
|
160
|
+
* While the checkbox is on, React's synthetic `keydown` for that specific Enter is
|
|
161
|
+
suppressed along with the composer command. This is intentional (no submit path may
|
|
162
|
+
remain) and affects no other event.
|
|
163
|
+
* The guard locates the composer card and the rendered control through the shipped
|
|
164
|
+
`[data-composer-card]` marker plus this plugin's own `data-enter-newline-seat`; the
|
|
165
|
+
draft host itself is recognised via `isContentEditable` (or `textarea`). A DSH release
|
|
166
|
+
that renames `data-composer-card` would make the plugin stop finding the control, and
|
|
167
|
+
the guard then falls back to its stored preference — so the checkbox keeps working,
|
|
168
|
+
while the "read exactly what the user sees" guarantee no longer holds. Re-check that
|
|
169
|
+
marker after a kernel upgrade.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Insert the client-only plugin entry. The package owns no Config, so the entry
|
|
2
|
+
# carries none: the host half exists only so the loader can mount it, and the whole
|
|
3
|
+
# feature is the browser half declared by `dsh.client` in package.json.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: enter-newline
|
|
6
|
+
name: 'dsh-client-ui-enter-newline'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `dsh-client-ui-enter-newline`.
|
|
3
|
+
*
|
|
4
|
+
* Feature: a checkbox in the top-right corner of the composer card labelled
|
|
5
|
+
* "Enter 键仅换行" (Enter inserts a newline only). Checked -> plain Enter inserts a
|
|
6
|
+
* line break and never submits. Unchecked -> the shipped composer behavior, where
|
|
7
|
+
* plain Enter sends and Shift+Enter inserts a line break. The default is unchecked.
|
|
8
|
+
*
|
|
9
|
+
* How the Enter guard works
|
|
10
|
+
* -------------------------
|
|
11
|
+
* The shipped composer binds its Enter command inside Lexical, which listens on the
|
|
12
|
+
* editor root element in the DOM bubble phase (`registerComposerKeymap` in
|
|
13
|
+
* `@deepseek-ai/dsh-client-ui-conversation`). Its handler calls `preventDefault()`
|
|
14
|
+
* and submits. A document-level *capture* listener therefore runs strictly earlier,
|
|
15
|
+
* and stopping propagation there keeps the command from ever reaching Lexical.
|
|
16
|
+
* The event is deliberately not `preventDefault()`ed, so the browser's own
|
|
17
|
+
* contenteditable behavior (insert a line break) still applies — exactly the
|
|
18
|
+
* Shift+Enter path.
|
|
19
|
+
*
|
|
20
|
+
* Because the bubble phase is cut, React's delegated synthetic `keydown` also never
|
|
21
|
+
* sees the event. That is deliberate: when the checkbox is on, Enter must not reach
|
|
22
|
+
* any submit path. Every other key, and every Enter while the checkbox is off, is
|
|
23
|
+
* left completely untouched.
|
|
24
|
+
*
|
|
25
|
+
* Markup contract this plugin relies on (all shipped `data-*`, not CSS hashes):
|
|
26
|
+
* `[data-composer-card]` the resident composer card
|
|
27
|
+
* `[data-composer-seat]` the composer seat wrapper (fallback anchor)
|
|
28
|
+
* The checkbox fills the composer's own `conversation.input.left` seat — the toolbar
|
|
29
|
+
* row's leading group, which renders immediately before the trailing group holding
|
|
30
|
+
* the model / reasoning-effort selector. The seat is session-scoped and never hidden
|
|
31
|
+
* by the toolbar's activity expansion, so the control lives exactly as long as the
|
|
32
|
+
* resident composer does.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
window.__ModuleLoader__.load({
|
|
36
|
+
id: 'dsh-client-ui-enter-newline',
|
|
37
|
+
factory: (require) => {
|
|
38
|
+
const module = { exports: {} }
|
|
39
|
+
const exports = module.exports
|
|
40
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
41
|
+
|
|
42
|
+
const React = require('react')
|
|
43
|
+
const { createElement: h, useCallback, useEffect, useRef, useState } = React
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------
|
|
46
|
+
// Constants
|
|
47
|
+
// ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const PLUGIN_ID = 'dsh-client-ui-enter-newline'
|
|
50
|
+
const CSS_TAG = PLUGIN_ID + '/client.css'
|
|
51
|
+
const STORAGE_KEY = 'dsh.enterNewlineOnly'
|
|
52
|
+
const SEAT_ATTR = 'data-enter-newline-seat'
|
|
53
|
+
const SEAT_SELECTOR = '[' + SEAT_ATTR + ']'
|
|
54
|
+
/** Composer toolbar seat: leading group, rendered before the model selector. */
|
|
55
|
+
const SLOT = 'conversation.input.left'
|
|
56
|
+
const CARD_SELECTOR = '[data-composer-card]'
|
|
57
|
+
|
|
58
|
+
const LABEL = 'Enter 键仅换行'
|
|
59
|
+
|
|
60
|
+
// ------------------------------------------------------------------
|
|
61
|
+
// Styles (namespace: `denl-`, theme tokens only)
|
|
62
|
+
//
|
|
63
|
+
// The seat is an inline cell of the composer toolbar row, whose own flex rules
|
|
64
|
+
// (`align-items:center`, `gap:12px`) already space it from the model selector to
|
|
65
|
+
// its right. Nothing here positions the control absolutely.
|
|
66
|
+
// ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const CSS = [
|
|
69
|
+
'.denl-root{display:inline-flex;align-items:center;min-width:0;flex:none}',
|
|
70
|
+
'.denl-row{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;-webkit-user-select:none;margin:0}',
|
|
71
|
+
'.denl-box{position:relative;flex:none;display:grid;place-items:center;width:14px;height:14px;border:1px solid var(--dsw-alias-border-l3);border-radius:4px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);transition:background .12s,border-color .12s}',
|
|
72
|
+
'.denl-row:hover .denl-box{border-color:var(--dsw-alias-label-tertiary)}',
|
|
73
|
+
'.denl-input{position:absolute;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}',
|
|
74
|
+
'.denl-root[data-on="1"] .denl-box{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-secondary)}',
|
|
75
|
+
'.denl-input:focus-visible+.denl-box{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}',
|
|
76
|
+
'.denl-check{opacity:0;transition:opacity .12s}',
|
|
77
|
+
'.denl-root[data-on="1"] .denl-check{opacity:1}',
|
|
78
|
+
'.denl-label{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;cursor:pointer}',
|
|
79
|
+
'.denl-root[data-on="1"] .denl-label{color:var(--dsw-alias-label-secondary)}'
|
|
80
|
+
].join('')
|
|
81
|
+
|
|
82
|
+
let cssDisposer = null
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Removes a diagnostic readout left behind by an earlier build of this plugin.
|
|
86
|
+
*
|
|
87
|
+
* That readout was a fixed-position element appended straight to `<body>` during
|
|
88
|
+
* development, so a hot reload orphaned it: no execution owned it any more, and it
|
|
89
|
+
* stayed on screen. The plugin now sweeps it away on activation, which both cleans
|
|
90
|
+
* existing pages and makes the removal self-healing for anyone else who ran that
|
|
91
|
+
* build. The id is private to this plugin, so nothing else can match it.
|
|
92
|
+
*/
|
|
93
|
+
function removeStaleReadout() {
|
|
94
|
+
if (typeof document === 'undefined') return
|
|
95
|
+
const stale = document.getElementById('denl-debug')
|
|
96
|
+
if (stale !== null && stale !== undefined && typeof stale.remove === 'function') stale.remove()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ensureCss() {
|
|
100
|
+
if (cssDisposer !== null) return
|
|
101
|
+
if (typeof document === 'undefined') return
|
|
102
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']') !== null) return
|
|
103
|
+
const tag = document.createElement('style')
|
|
104
|
+
tag.dataset.plugin = PLUGIN_ID
|
|
105
|
+
tag.dataset.pluginCss = CSS_TAG
|
|
106
|
+
tag.textContent = CSS
|
|
107
|
+
document.head.appendChild(tag)
|
|
108
|
+
cssDisposer = () => {
|
|
109
|
+
tag.remove()
|
|
110
|
+
cssDisposer = null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------
|
|
115
|
+
// Preference store (page-global, localStorage-backed)
|
|
116
|
+
//
|
|
117
|
+
// The state deliberately lives on a single object parked on `window` rather than
|
|
118
|
+
// in module-local variables. A client plugin is re-executed on every hot reload,
|
|
119
|
+
// and each execution gets fresh module locals: a listener installed by one
|
|
120
|
+
// execution would then read a stale flag while the control rendered by another
|
|
121
|
+
// execution had already been switched on. One shared record per page makes every
|
|
122
|
+
// execution — and therefore every guard listener and every rendered control —
|
|
123
|
+
// observe exactly the same value.
|
|
124
|
+
// ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const STORE_KEY = '__dshEnterNewlineStore'
|
|
127
|
+
|
|
128
|
+
function readStored() {
|
|
129
|
+
try {
|
|
130
|
+
return window.localStorage.getItem(STORAGE_KEY) === '1'
|
|
131
|
+
} catch {
|
|
132
|
+
// Storage blocked (private mode): fall back to the shared in-page record.
|
|
133
|
+
const fallback = window[STORE_KEY]
|
|
134
|
+
return fallback !== undefined && fallback.value === true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createStore() {
|
|
139
|
+
return {
|
|
140
|
+
/** Default is unchecked: the shipped composer behavior. */
|
|
141
|
+
value: readStored(),
|
|
142
|
+
listeners: new Set()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Shared across bundle executions; created once per page. */
|
|
147
|
+
const store = (() => {
|
|
148
|
+
const existing = window[STORE_KEY]
|
|
149
|
+
if (existing !== undefined && existing !== null && existing.listeners instanceof Set) return existing
|
|
150
|
+
const created = createStore()
|
|
151
|
+
window[STORE_KEY] = created
|
|
152
|
+
return created
|
|
153
|
+
})()
|
|
154
|
+
|
|
155
|
+
function getNewlineOnly() {
|
|
156
|
+
return store.value === true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The authoritative preference for the Enter guard.
|
|
161
|
+
*
|
|
162
|
+
* The rendered control is read first: a checkbox's `checked` property is the value
|
|
163
|
+
* the user is looking at, and it survives anything that can desynchronize an
|
|
164
|
+
* in-memory copy — a hot reload that leaves an older bundle execution's listener
|
|
165
|
+
* installed, or a storage write that never landed. The shared record is only the
|
|
166
|
+
* fallback for the moments when the control is not mounted.
|
|
167
|
+
*/
|
|
168
|
+
function preferenceIsNewlineOnly() {
|
|
169
|
+
if (typeof document !== 'undefined') {
|
|
170
|
+
const control = document.querySelector(CARD_SELECTOR + ' ' + SEAT_SELECTOR + ' input[type="checkbox"]')
|
|
171
|
+
if (control !== null && control !== undefined) return control.checked === true
|
|
172
|
+
}
|
|
173
|
+
return getNewlineOnly()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function setNewlineOnly(next) {
|
|
177
|
+
const value = next === true
|
|
178
|
+
if (value === store.value) return
|
|
179
|
+
store.value = value
|
|
180
|
+
try {
|
|
181
|
+
window.localStorage.setItem(STORAGE_KEY, value ? '1' : '0')
|
|
182
|
+
} catch {
|
|
183
|
+
// Non-persistent storage: the shared in-page record keeps the value.
|
|
184
|
+
}
|
|
185
|
+
for (const listener of Array.from(store.listeners)) {
|
|
186
|
+
try {
|
|
187
|
+
listener()
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error('[enter-newline] listener failed', error)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function subscribeNewlineOnly(listener) {
|
|
195
|
+
store.listeners.add(listener)
|
|
196
|
+
return () => {
|
|
197
|
+
store.listeners.delete(listener)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ------------------------------------------------------------------
|
|
202
|
+
// Composer Enter guard
|
|
203
|
+
// ------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** Prefix keys that must keep their shipped meaning even in newline-only mode. */
|
|
206
|
+
function hasModifier(event) {
|
|
207
|
+
return event.shiftKey || event.ctrlKey || event.metaKey || event.altKey || event.getModifierState('AltGraph')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether `node` is this plugin's own control (or inside it).
|
|
212
|
+
*
|
|
213
|
+
* Only the ancestor chain is consulted. An earlier revision also excluded anything
|
|
214
|
+
* inside a composer card that merely *contained* the seat — which is true of the
|
|
215
|
+
* composer's own draft editor, so the guard silently skipped exactly the events it
|
|
216
|
+
* exists to handle. The seat is always an ancestor of the control's own nodes.
|
|
217
|
+
*/
|
|
218
|
+
function inOwnSeat(node) {
|
|
219
|
+
let current = node
|
|
220
|
+
while (current !== null && current !== undefined) {
|
|
221
|
+
if (current.nodeType === 1 && typeof current.hasAttribute === 'function' && current.hasAttribute(SEAT_ATTR)) {
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
current = current.parentNode
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The composer's own draft host: an editable element inside a composer card.
|
|
231
|
+
*
|
|
232
|
+
* Editable-ness is read from the live node (`isContentEditable`, which also covers
|
|
233
|
+
* `contenteditable="plaintext-only"`) instead of a fixed attribute value, and
|
|
234
|
+
* `textarea` is accepted too, so a kernel change in how the draft is hosted cannot
|
|
235
|
+
* silently disable the guard. The composer-card requirement keeps the guard from
|
|
236
|
+
* ever touching an editor outside the composer.
|
|
237
|
+
*/
|
|
238
|
+
function isComposerEditor(node) {
|
|
239
|
+
if (node === null || node === undefined || node.nodeType !== 1) return false
|
|
240
|
+
if (typeof node.closest !== 'function') return false
|
|
241
|
+
if (node.isContentEditable !== true && node.tagName !== 'TEXTAREA') return false
|
|
242
|
+
return node.closest(CARD_SELECTOR) !== null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function onDocumentKeydown(event) {
|
|
246
|
+
if (!preferenceIsNewlineOnly()) return
|
|
247
|
+
if (event.defaultPrevented) return
|
|
248
|
+
if (event.key !== 'Enter' && event.keyCode !== 13) return
|
|
249
|
+
if (event.isComposing || event.keyCode === 229) return
|
|
250
|
+
if (hasModifier(event)) return
|
|
251
|
+
const target = event.target
|
|
252
|
+
if (!isComposerEditor(target)) return
|
|
253
|
+
if (inOwnSeat(target)) return
|
|
254
|
+
// Swallow the composer's Enter command; the browser inserts the line break.
|
|
255
|
+
event.stopPropagation()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Keep keyboard focus in the draft and hand focus to the checkbox, so toggling
|
|
260
|
+
* the preference never interrupts typing while the control stays tab-reachable.
|
|
261
|
+
*/
|
|
262
|
+
function focusArtifact(event) {
|
|
263
|
+
const input = event.currentTarget.querySelector('input')
|
|
264
|
+
if (input !== null) input.focus({ preventScroll: true })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Own the Enter guard for exactly as long as the plugin is active.
|
|
269
|
+
*
|
|
270
|
+
* The listener is registered on `window` *and* `document`, both in the capture
|
|
271
|
+
* phase: whichever runs first stops the event before the editor's own bubble-phase
|
|
272
|
+
* host and any delegated listener above it can see it. Stopping twice is harmless.
|
|
273
|
+
*/
|
|
274
|
+
function installEnterGuard() {
|
|
275
|
+
if (typeof document === 'undefined') return () => {}
|
|
276
|
+
const owner = typeof window === 'undefined' ? null : window
|
|
277
|
+
document.addEventListener('keydown', onDocumentKeydown, true)
|
|
278
|
+
if (owner !== null) owner.addEventListener('keydown', onDocumentKeydown, true)
|
|
279
|
+
return () => {
|
|
280
|
+
document.removeEventListener('keydown', onDocumentKeydown, true)
|
|
281
|
+
if (owner !== null) owner.removeEventListener('keydown', onDocumentKeydown, true)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ------------------------------------------------------------------
|
|
286
|
+
// React control
|
|
287
|
+
// ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function syncSeatFlag(node, on) {
|
|
290
|
+
if (node === null) return
|
|
291
|
+
if (on) node.setAttribute('data-on', '1')
|
|
292
|
+
else node.removeAttribute('data-on')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function EnterNewlineToggle() {
|
|
296
|
+
const [on, setOn] = useState(getNewlineOnly)
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
const listener = () => setOn(getNewlineOnly())
|
|
300
|
+
const unsubscribe = subscribeNewlineOnly(listener)
|
|
301
|
+
// The shared record may have moved on between render and effect (another
|
|
302
|
+
// execution's control, or a reload), so re-read it once on mount.
|
|
303
|
+
listener()
|
|
304
|
+
return unsubscribe
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
const onChange = useCallback((event) => {
|
|
308
|
+
// Read the control's own state, not the event payload: the payload is the
|
|
309
|
+
// same either way, and the DOM property is the value the user sees.
|
|
310
|
+
setNewlineOnly(event.currentTarget.checked === true)
|
|
311
|
+
// `mousedown` was suppressed to protect the draft's focus, so move the
|
|
312
|
+
// focus ring to the control explicitly and keep it operable from the keyboard.
|
|
313
|
+
focusArtifact(event)
|
|
314
|
+
}, [])
|
|
315
|
+
|
|
316
|
+
const onRowMouseDown = useCallback((event) => {
|
|
317
|
+
// Keep the caret in the draft instead of moving focus to the checkbox, so
|
|
318
|
+
// toggling the preference never interrupts typing.
|
|
319
|
+
event.preventDefault()
|
|
320
|
+
}, [])
|
|
321
|
+
|
|
322
|
+
const box = h(
|
|
323
|
+
'span',
|
|
324
|
+
{ className: 'denl-box' },
|
|
325
|
+
h(
|
|
326
|
+
'svg',
|
|
327
|
+
{
|
|
328
|
+
className: 'denl-check',
|
|
329
|
+
width: 10,
|
|
330
|
+
height: 10,
|
|
331
|
+
viewBox: '0 0 12 12',
|
|
332
|
+
fill: 'none',
|
|
333
|
+
'aria-hidden': 'true'
|
|
334
|
+
},
|
|
335
|
+
h('path', {
|
|
336
|
+
d: 'M2 6.4 4.6 9 10 3.2',
|
|
337
|
+
stroke: 'currentColor',
|
|
338
|
+
strokeWidth: 1.8,
|
|
339
|
+
strokeLinecap: 'round',
|
|
340
|
+
strokeLinejoin: 'round'
|
|
341
|
+
})
|
|
342
|
+
),
|
|
343
|
+
h('input', {
|
|
344
|
+
className: 'denl-input',
|
|
345
|
+
type: 'checkbox',
|
|
346
|
+
checked: on,
|
|
347
|
+
onChange,
|
|
348
|
+
'aria-label': LABEL,
|
|
349
|
+
tabIndex: 0
|
|
350
|
+
})
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return h(
|
|
354
|
+
'div',
|
|
355
|
+
{
|
|
356
|
+
className: 'denl-root',
|
|
357
|
+
// The seat's checked styling is a container attribute so the rest of the
|
|
358
|
+
// markup stays declarative; a ref callback keeps it in step with the render
|
|
359
|
+
// that owns this state, with no extra effect or keyed pass.
|
|
360
|
+
ref: (node) => syncSeatFlag(node, on),
|
|
361
|
+
[SEAT_ATTR]: 'true',
|
|
362
|
+
onClick: (event) => event.stopPropagation(),
|
|
363
|
+
onPointerDown: (event) => event.stopPropagation()
|
|
364
|
+
},
|
|
365
|
+
h(
|
|
366
|
+
'label',
|
|
367
|
+
{ className: 'denl-row', title: LABEL, onMouseDown: onRowMouseDown },
|
|
368
|
+
box,
|
|
369
|
+
h('span', { className: 'denl-label' }, LABEL)
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ------------------------------------------------------------------
|
|
375
|
+
// Plugin entry
|
|
376
|
+
// ------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
function apply(ctx) {
|
|
379
|
+
// Runs before the stylesheet short-circuit below, so the sweep happens on every
|
|
380
|
+
// activation — including a hot reload that inherits an already-installed sheet.
|
|
381
|
+
removeStaleReadout()
|
|
382
|
+
ensureCss()
|
|
383
|
+
|
|
384
|
+
ctx.slots.inject(SLOT, () =>
|
|
385
|
+
ctx.slots.register(
|
|
386
|
+
{
|
|
387
|
+
name: SLOT,
|
|
388
|
+
id: 'enter-newline-toggle',
|
|
389
|
+
order: 100,
|
|
390
|
+
label: LABEL
|
|
391
|
+
},
|
|
392
|
+
EnterNewlineToggle
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
ctx.effect(() => installEnterGuard(), 'enter-newline: composer Enter guard')
|
|
397
|
+
ctx.effect(() => () => {
|
|
398
|
+
if (cssDisposer !== null) cssDisposer()
|
|
399
|
+
}, 'enter-newline: stylesheet')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
exports.apply = apply
|
|
403
|
+
exports.inject = ['slots']
|
|
404
|
+
return module.exports
|
|
405
|
+
}
|
|
406
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-client-ui-enter-newline`.
|
|
3
|
+
*
|
|
4
|
+
* The whole feature lives in the browser: a checkbox rendered into the composer's
|
|
5
|
+
* floating overlay plus a document-level Enter guard. The host half therefore only
|
|
6
|
+
* has to exist as a loadable Cordis entry — it declares no Config, provides no
|
|
7
|
+
* Service, contributes no prompt text, and registers nothing model-facing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Activate the host half.
|
|
12
|
+
*
|
|
13
|
+
* Intentionally empty: this plugin owns no Host state. The loader entry in
|
|
14
|
+
* `cordis.patch.yml` supplies the id and module name, matching the pattern used by
|
|
15
|
+
* the other client-only plugins in this profile.
|
|
16
|
+
*/
|
|
17
|
+
export function apply() {}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-client-ui-enter-newline",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH Web client plugin: an \"Enter inserts a newline only\" checkbox beside the composer's model selector, which suppresses Enter-to-send while checked.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"inject": [
|
|
29
|
+
"react",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
31
|
+
],
|
|
32
|
+
"platform": "web"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"dsh-plugin",
|
|
38
|
+
"deepseek-harness",
|
|
39
|
+
"composer",
|
|
40
|
+
"enter",
|
|
41
|
+
"newline"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|