@maci0/dsh-ponytail 0.0.0-stage → 0.18.3

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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-ponytail contributors
4
+ Copyright (c) 2026 DietrichGebert (https://github.com/DietrichGebert/ponytail)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,185 @@
1
- # Temporary Holding Version
1
+ # dsh-ponytail
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your agent adds a cache class, a config flag, and a factory for one product. This
4
+ plugin makes it stop and write the one line that works instead. The ladder:
5
+ question whether the thing needs to exist, reuse what is already in the repo, then
6
+ the standard library, then the platform, then an installed dependency, then one
7
+ line. Stop at the first rung that holds. Adapted from
8
+ [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail) (MIT): "He
9
+ says nothing. He writes one line. It works."
10
+
11
+ ## What you get
12
+
13
+ - **The ladder on every request.** While any level except `off` is active, the
14
+ mode-filtered ruleset joins the system prompt.
15
+ - **Six skills** the `skill` tool can load, so they also appear as
16
+ `/ponytail-review`, `/ponytail-audit`, … in the composer: `ponytail`,
17
+ `ponytail-review`, `ponytail-audit`, `ponytail-debt`, `ponytail-gain`,
18
+ `ponytail-help`.
19
+ - **Two ways to switch level.** The model calls the `ponytail` tool; you type
20
+ `/ponytail <level>` or just say **stop ponytail**.
21
+ - **A settings card and a composer chip**, so the level is visible without opening Settings.
22
+ - **No unrequested abstractions, no boilerplate.** Deletion over addition, boring
23
+ over clever.
24
+
25
+ ## Install
26
+
27
+ > **Install it as a bundle.** `dsh plugin add …` mounts the row from the
28
+ > package's own patch layer, which is what the settings editor can write to. A
29
+ > row added with `--patch` is an overlay: it disappears at the next start, and
30
+ > the Plugins card cannot save into it (the editor refuses a write an overlay
31
+ > would win).
32
+
33
+ ```sh
34
+ dsh plugin --profile web add github:maci0/dsh-ponytail#v0.18.3
35
+ ```
36
+
37
+ Pin a release tag: a bare `github:` spec floats on `main`. To upgrade, run the
38
+ same command with the newer tag, then restart `dsh web` (bundle layers compose
39
+ at boot).
40
+
41
+ ## Use it
42
+
43
+ ```
44
+ /ponytail ultra -> Ponytail level: ultra (was full).
45
+ /ponytail -> Ponytail level: ultra.
46
+ /ponytail off -> Ponytail off (was ultra). Normal behavior.
47
+ ```
48
+
49
+ Then ask for the work. This is the `full` example from the skill itself:
50
+
51
+ ```
52
+ > Add a cache for these API responses.
53
+
54
+ `@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class,
55
+ add when lru_cache measurably falls short.
56
+ ```
57
+
58
+ Model side, the tool takes one optional argument: `{"mode": "lite"}` sets and
59
+ persists a level, and calling it with no argument reports the current one.
60
+
61
+ Typing **stop ponytail** or **normal mode** as an ordinary message has the same
62
+ effect as `/ponytail off`, and it lands on the turn that carried it. Only the
63
+ human's own words count: injected context riding the same event stream cannot
64
+ toggle the level, and the message must *be* the command, so "add a normal mode
65
+ toggle" is left alone.
66
+
67
+ ### Levels
68
+
69
+ | Level | Behavior | Persisted |
70
+ |---|---|---|
71
+ | `lite` | Build what was asked, name the lazier alternative in one line. | yes |
72
+ | `full` | The ladder enforced. Default. | yes |
73
+ | `ultra` | YAGNI extremist: challenge the requirement before building it. | yes |
74
+ | `review` | Over-engineering review; points at the `ponytail-review` skill. | no, session only |
75
+ | `off` | No injection. Normal behavior. | yes |
76
+
77
+ The four persistable levels live in the `ponytail` settings namespace, so the
78
+ card, the chip, the tool, and `/ponytail` agree and the choice survives a
79
+ restart. `review` stays session-only because it is a review mode, not a level a
80
+ deployment should start in; the tool and the command still accept it. The card
81
+ and the chip show the level the host is using, so they show `review`, and any level
82
+ kept only for this session because the settings write failed, labelled as
83
+ session-only.
84
+
85
+ Lazy is not negligent. Understanding the problem, trust-boundary validation,
86
+ data-loss handling, security, and accessibility are never on the chopping block.
87
+
88
+ ## Configure
89
+
90
+ | Field | Default | Meaning |
91
+ |---|---|---|
92
+ | `defaultMode` | `full` | Startup level. Must be `off`, `lite`, `full`, or `ultra`. |
93
+
94
+ The exported `Config` schema defaults the field, so a row that omits `defaultMode`
95
+ starts in `full`. The user's settings namespace overrides it once a level is chosen
96
+ in the card or by `/ponytail`. An invalid level fails while the plugin loads rather
97
+ than quietly doing the wrong thing: the loader validates the row against the
98
+ exported schema.
99
+
100
+ To set the startup level from the environment, override the row in your profile's
101
+ `cordis.patch.yml` with a `!!js` expression (same `id`, which replaces the row's
102
+ whole `config`):
103
+
104
+ ```yaml
105
+ - id: ponytail
106
+ config:
107
+ defaultMode: !!js process.env.PONYTAIL_DEFAULT_MODE ?? 'full'
108
+ ```
109
+
110
+ Override the row this way; do **not** paste the bundle's `insert` of the
111
+ `id: ponytail` row into your profile's `cordis.patch.yml`: `insert` does not
112
+ dedupe ids, and a second row mounts the plugin twice.
113
+
114
+ ## How it works
115
+
116
+ The package declares `dsh.bundle`, so `dsh plugin add` appends it to
117
+ `dsh.profile.bundles` and the row in its own `cordis.patch.yml` applies as a layer.
118
+
119
+ The host half mounts through public Cordis extension points: `systemPrompt.section`,
120
+ `skills.registerProvider`, `tools.register`, `commands.register`,
121
+ `loader/volatile-update` (the settings document writes the row's volatile
122
+ `defaultMode`), `session/event` for the message switch, and `webServer` for
123
+ `GET /ponytail/level`, which answers `{ mode, source }` (`settings` or `session`)
124
+ behind the `connection` trust fence. The browser half reads that route on every
125
+ settings change and every 5 seconds, draws its card into the public `plugins.row.config` slot from `configForms`, registers its
126
+ copy through `locale.register`, and draws its chip into `conversation.input.left`, so
127
+ this plugin needs no client change of its own.
128
+
129
+ The entry point is the built `lib/index.js` (declarations in `lib/types/`); `bun run
130
+ build` regenerates it from `src/`. `lib/client.js` is hand-authored plain JavaScript:
131
+ the client module system serves it as a lazy-CJS factory on `window.__ModuleLoader__`
132
+ because the package exports `./client`, and it is not built. Skills come from
133
+ `skills/<name>/SKILL.md` with frontmatter parsed by `yaml`; the provider takes its rank
134
+ and name grammar from `@deepseek-ai/dsh-skill`, projects `disable-model-invocation`,
135
+ `user-invocable`, and `whenToUse`, and settles on the lookup's abort signal. Tools use
136
+ `defineTool` from `@deepseek-ai/dsh-tools` and forward `exec.signal`. Only
137
+ `skills/ponytail/SKILL.md` is the source of truth for the ruleset; this README keeps no
138
+ second copy.
139
+
140
+ ## Limits
141
+
142
+ - **It does not skip comprehension.** The ladder shortens the solution, never the
143
+ reading. The skill says so in its own text.
144
+ - **The level is process-wide.** One prompt section, one namespace value: every
145
+ agent in the process shares it.
146
+ - **External subagents ignore it.** In-process children inherit the ruleset, but
147
+ `subagent-claude-code` and `subagent-codex` spawn their own CLI with its own
148
+ system prompt, and no harness extension point wraps a spawn.
149
+ - **Two locales.** The card and the chip ship `en` and `zh`; any other locale
150
+ falls back through the service's own chain.
151
+ - **Host source edits need `bun run build` and a restart**, because the Loader
152
+ loads `lib/index.js` and a bundle layer composes at boot.
153
+ - **Browser-half edits need a page refresh.**
154
+ - **A level change is not a session event.** A replayed session shows the ruleset
155
+ each request already carried.
156
+
157
+ ## Development
158
+
159
+ ```sh
160
+ bun install # the client packages are optional peers and stay uninstalled
161
+ bun run build # tsc -p tsconfig.build.json -> lib/index.js + lib/types/
162
+ bun test # every tests/*.test.ts, no build step
163
+ bun run typecheck # tsc -p tsconfig.json
164
+ ```
165
+
166
+ dsh loads plugins on Node ^22.19.0 || >=24.0.0; development and tests run on bun.
167
+
168
+ For local development, install the checkout into a profile with
169
+ `dsh plugin --profile <name> add <path-to-checkout>`.
170
+
171
+ The suite covers level normalization and filtering, the fake-host surface, the skills
172
+ provider, the client card, and a real Cordis composition mount next to the real skill
173
+ registry. To uninstall, run `dsh plugin --profile web remove @maci0/dsh-ponytail`; that removes
174
+ the dependency and the bundle layer together.
175
+
176
+ ## Attribution and license
177
+
178
+ MIT. Skill content and mode semantics: © DietrichGebert
179
+ ([ponytail](https://github.com/DietrichGebert/ponytail)). DSH port: see `LICENSE`.
180
+
181
+ The six `skills/*/SKILL.md` files are verbatim copies, so they carry upstream's figures
182
+ and citations rather than this package's. `/ponytail-gain` therefore reports the original
183
+ five-task single-shot benchmark (80–94% fewer lines, 47–77% cheaper, 3–6× faster),
184
+ which upstream's own README has since revised to roughly 54% fewer lines, 20% cheaper
185
+ and 27% faster on its agentic benchmark.
@@ -0,0 +1,15 @@
1
+ # The dsh-ponytail bundle patch: applied automatically when a profile lists
2
+ # this bundle (`dsh plugin add`/`update` appends the package to
3
+ # dsh.profile.bundles). Users override this row from their profile's own
4
+ # cordis.patch.yml (live-watched; dsh.profile.bundles is frozen at boot) with a
5
+ # `- id: ponytail` row, which replaces the row's whole `config`.
6
+ # `name` stays a bare package specifier: the browser half is served by the
7
+ # client module system, which resolves the Loader entry's package, reads its
8
+ # `dsh.client` manifest, and serves `exports["./client"]`.
9
+ # Do not also insert this same row into the profile patch: insert does not
10
+ # dedupe ids, and a second row would register the plugin twice.
11
+ - insert:
12
+ - id: ponytail
13
+ name: '@maci0/dsh-ponytail'
14
+ config:
15
+ defaultMode: full
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <circle cx="12" cy="11.5" r="4.2" stroke="#0F6E56" stroke-width="2.2"/>
3
+ <circle cx="12" cy="24.5" r="4.2" stroke="#0F6E56" stroke-width="2.2"/>
4
+ <path d="M15.4 13.8L28 25.5M15.4 22.2L28 10.5" stroke="#1F8A70" stroke-width="2.2" stroke-linecap="round"/>
5
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * dsh-ponytail browser half.
3
+ *
4
+ * Two surfaces read one settings namespace:
5
+ *
6
+ * - the Ponytail card on the Plugins page, keyed on the
7
+ * `ponytail` namespace the host half registers; and
8
+ * - a read-only level chip in the composer tool row (`conversation.input.left`),
9
+ * so the active level is visible without opening Settings.
10
+ *
11
+ * Both read `ctx.configForms`, so they cannot disagree, and both take their
12
+ * copy from the `ponytail` locale namespace registered here (en/zh), which is
13
+ * also why every label has an English and a Chinese entry.
14
+ *
15
+ * Chrome is a stylesheet, not inline style objects: the module system claims
16
+ * every `<style>` tag a factory appends while it materializes and removes it
17
+ * when the package unloads, so the tags cost nothing to own. It also keeps
18
+ * state changes out of React's inline-style diffing, which is what silently
19
+ * blanked a deselected pill's border (a longhand removed against a set
20
+ * shorthand decomposes the shorthand).
21
+ *
22
+ * This file is plain JavaScript on purpose. The client module system serves a
23
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
24
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
25
+ * plugin can author it directly instead of reproducing the repository's tsdown
26
+ * client preset. `react` is provided by the module system; nothing else is
27
+ * required here.
28
+ */
29
+
30
+ window.__ModuleLoader__.load({
31
+ id: '@maci0/dsh-ponytail',
32
+
33
+ factory: (require) => {
34
+ var module = { exports: {} }
35
+ var exports = module.exports
36
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
37
+
38
+ const React = require('react')
39
+
40
+ /** Settings namespace shared with the host half; also this card's slot key. */
41
+ const NAMESPACE = 'ponytail'
42
+
43
+ /** Locale namespace for this plugin's copy: both surfaces read one dictionary. */
44
+ const LOCALE_NS = 'ponytail'
45
+
46
+ /**
47
+ * The host half's route reporting the level in use and its source. Same
48
+ * origin, so the session cookie rides along.
49
+ */
50
+ const LEVEL_ROUTE = '/ponytail/level'
51
+
52
+ /**
53
+ * How often a mounted surface re-reads the route. A session-local level
54
+ * changes without a settings write, so no snapshot change announces it.
55
+ */
56
+ const LEVEL_POLL_MS = 5000
57
+
58
+ /** Every class is `dp-`-prefixed: the sheet lands in the page's own document. */
59
+ const CSS = [
60
+ '.dp-page{display:flex;flex-direction:column;gap:10px}',
61
+ '.dp-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:10px}',
62
+ '.dp-row{display:flex;flex-wrap:wrap;gap:8px}',
63
+ '.dp-pill{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
64
+ '.dp-pill-selected{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4)}',
65
+ '.dp-pill:disabled{cursor:default;opacity:.5}',
66
+ '.dp-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
67
+ '.dp-status{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
68
+ '.dp-reset{appearance:none;font:inherit;font-size:12px;line-height:1.5;padding:3px 10px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
69
+ '.dp-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
70
+ '.dp-chip{display:inline-flex;align-items:center;height:24px;padding:0 10px;max-width:180px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;line-height:1.4;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
71
+ ].join('')
72
+
73
+ // Appended while the factory materializes: the module system claims the tag
74
+ // for this package and disposes it on unload. Guarded because the node unit
75
+ // tests evaluate this file without a DOM.
76
+ if (typeof document !== 'undefined') {
77
+ const style = document.createElement('style')
78
+ style.textContent = CSS
79
+ document.head.append(style)
80
+ }
81
+
82
+ const en = {
83
+ description: 'Lazy senior dev mode, level: {level}{note}.',
84
+ overridden: ' (overridden)',
85
+ fromSession: ' (this session only)',
86
+ sourceSession: 'Active for this session only; it is not saved in your settings.',
87
+ levelReview: 'Review',
88
+ hintReview: 'Over-engineering review: the model loads the ponytail-review skill.',
89
+ levelGroup: 'Ponytail level',
90
+ indicator: 'Ponytail: {level}',
91
+ levelOff: 'Off',
92
+ levelLite: 'Lite',
93
+ levelFull: 'Full',
94
+ levelUltra: 'Ultra',
95
+ hintOff: 'No ruleset injected. Normal behavior.',
96
+ hintLite: 'Build what was asked, and name the lazier alternative in one line.',
97
+ hintFull: 'The ladder enforced: YAGNI, reuse, stdlib, native platform, one line, then the minimum that works.',
98
+ hintUltra: 'Challenge whether the requirement needs to exist before building it.',
99
+ persists: 'Applies to every request and persists in your settings.',
100
+ readOnly: 'Read-only: settings are not persisted in this deployment.',
101
+ reset: 'Reset',
102
+ refused: 'The Host refused the change. Try again.',
103
+ }
104
+
105
+ const zh = {
106
+ description: '懒人资深开发模式,级别:{level}{note}。',
107
+ overridden: '(已覆盖)',
108
+ fromSession: '(仅本会话)',
109
+ sourceSession: '仅在本会话生效,不会保存到你的设置中。',
110
+ levelReview: '审查',
111
+ hintReview: '过度设计审查:模型加载 ponytail-review 技能。',
112
+ levelGroup: 'Ponytail 级别',
113
+ indicator: 'Ponytail:{level}',
114
+ levelOff: '关闭',
115
+ levelLite: '轻量',
116
+ levelFull: '完整',
117
+ levelUltra: '极端',
118
+ hintOff: '不注入任何规则集,行为如常。',
119
+ hintLite: '按要求实现,并用一行指出更省的做法。',
120
+ hintFull: '强制执行阶梯:YAGNI、复用、标准库、平台原生、已装依赖、一行,然后才是能用的最少代码。',
121
+ hintUltra: '动手之前先质疑这个需求是否必须存在。',
122
+ persists: '对每个请求生效,并保存在你的设置中。',
123
+ readOnly: '只读:此部署不会持久化设置。',
124
+ reset: '重置',
125
+ refused: 'Host 拒绝了更改,请重试。',
126
+ }
127
+
128
+ /** Levels a user may persist. `review` is session-only and lives on the command. */
129
+ const LEVELS = [
130
+ { value: 'off', label: 'levelOff', hint: 'hintOff' },
131
+ { value: 'lite', label: 'levelLite', hint: 'hintLite' },
132
+ { value: 'full', label: 'levelFull', hint: 'hintFull' },
133
+ { value: 'ultra', label: 'levelUltra', hint: 'hintUltra' },
134
+ ]
135
+
136
+ /** Every level the host may report: the persisted ones plus session-only `review`. */
137
+ const KNOWN_LEVELS = [...LEVELS, { value: 'review', label: 'levelReview', hint: 'hintReview' }]
138
+
139
+ /**
140
+ * Localized name of a level, falling back to the raw value for one this card
141
+ * does not offer (a hand-edited document, a future level).
142
+ * @param t - translate function bound to this plugin's namespace.
143
+ * @param mode - level value.
144
+ * @returns the display name.
145
+ */
146
+ function levelLabel(t, mode) {
147
+ const level = KNOWN_LEVELS.filter((candidate) => candidate.value === mode)[0]
148
+ return level === undefined ? mode : t(level.label)
149
+ }
150
+
151
+ /**
152
+ * Bind one scope to a React subscription.
153
+ * @param scope - a scope bound to the ponytail settings namespace.
154
+ * @returns a hook reading that scope's current snapshot.
155
+ */
156
+ function useScope(scope) {
157
+ const subscribe = (listener) => scope.subscribe(listener)
158
+ const getSnapshot = () => scope.getSnapshot()
159
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
160
+ }
161
+
162
+ /**
163
+ * Read a snapshot's level. A namespace the deployment does not serve reports
164
+ * no value, which every surface renders as nothing.
165
+ * @param snapshot - the settings scope snapshot.
166
+ * @returns the active level value, or `undefined` when unreadable.
167
+ */
168
+ function modeOf(snapshot) {
169
+ if (snapshot.status !== 'ready') return undefined
170
+ const value = snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
171
+ return typeof value.defaultMode === 'string' ? value.defaultMode : 'full'
172
+ }
173
+
174
+ /**
175
+ * Read the level the host half is using, re-reading on every settings
176
+ * change and on a timer. `null` until the route answers, and whenever it
177
+ * cannot (an older host half, a rejected request): the surfaces then fall
178
+ * back to the settings document.
179
+ * @param snapshot - the settings scope snapshot; a new one triggers a read.
180
+ * @returns `{ mode, source }` from the host, or `null`.
181
+ */
182
+ function useHostLevel(snapshot) {
183
+ const [level, setLevel] = React.useState(null)
184
+ React.useEffect(() => {
185
+ let live = true
186
+ const controller = new AbortController()
187
+ const read = () => {
188
+ fetch(LEVEL_ROUTE, { headers: { accept: 'application/json' }, signal: controller.signal })
189
+ .then((response) => (response.ok ? response.json() : null))
190
+ .then((body) => { if (live) setLevel(hostLevelOf(body)) })
191
+ .catch(() => { if (live) setLevel(null) })
192
+ }
193
+ read()
194
+ const timer = setInterval(read, LEVEL_POLL_MS)
195
+ return () => {
196
+ live = false
197
+ controller.abort()
198
+ clearInterval(timer)
199
+ }
200
+ }, [snapshot])
201
+ return level
202
+ }
203
+
204
+ /**
205
+ * Validate the route's answer: a JSON body is input from the wire.
206
+ * @param body - the parsed response.
207
+ * @returns `{ mode, source }` when both are strings, else `null`.
208
+ */
209
+ function hostLevelOf(body) {
210
+ if (body === null || typeof body !== 'object') return null
211
+ if (typeof body.mode !== 'string' || typeof body.source !== 'string') return null
212
+ return { mode: body.mode, source: body.source }
213
+ }
214
+
215
+ /**
216
+ * The level a surface shows: the host's when it answered, else the
217
+ * settings document's. `undefined` while the namespace is not served.
218
+ * @param snapshot - the settings scope snapshot.
219
+ * @param host - the route's answer, or `null`.
220
+ * @returns the level value, or `undefined`.
221
+ */
222
+ function shownMode(snapshot, host) {
223
+ const documented = modeOf(snapshot)
224
+ if (documented === undefined) return undefined
225
+ return host === null ? documented : host.mode
226
+ }
227
+
228
+ /**
229
+ * Build the card component over one bound settings scope.
230
+ * @param scope - the scope bound to the ponytail namespace.
231
+ * @param t - translate function bound to this plugin's locale namespace.
232
+ * @returns the component the slot renders.
233
+ */
234
+ function createCard(scope, t) {
235
+ const usePonytail = useScope(scope)
236
+
237
+ return function PonytailCard(props) {
238
+ const snapshot = usePonytail()
239
+ const [error, setError] = React.useState(null)
240
+ const [pending, setPending] = React.useState(false)
241
+ const host = useHostLevel(snapshot)
242
+
243
+ const current = shownMode(snapshot, host)
244
+ // A namespace this deployment does not serve renders no trace of itself.
245
+ if (current === undefined) return null
246
+
247
+ const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
248
+ const overridden = Object.prototype.hasOwnProperty.call(user, 'defaultMode')
249
+ const disabled = !snapshot.writable || pending
250
+ const selected = KNOWN_LEVELS.filter((level) => level.value === current)[0]
251
+ const sessionOnly = host !== null && host.source === 'session'
252
+ if (props != null && props.view === 'summary') {
253
+ return t('description', {
254
+ level: levelLabel(t, current),
255
+ note: sessionOnly ? t('fromSession') : overridden ? t('overridden') : '',
256
+ })
257
+ }
258
+
259
+ const report = (cause) => {
260
+ setError(cause instanceof Error ? cause.message : String(cause))
261
+ }
262
+ const write = (run) => {
263
+ if (disabled) return
264
+ setError(null)
265
+ setPending(true)
266
+ new Promise((resolve) => { resolve(run()) }).then((accepted) => {
267
+ if (accepted === false) setError(t('refused'))
268
+ }).catch(report).finally(() => { setPending(false) })
269
+ }
270
+
271
+ return React.createElement(
272
+ 'div',
273
+ { className: 'dp-page' },
274
+ React.createElement(
275
+ 'div',
276
+ { className: 'dp-body' },
277
+ React.createElement(
278
+ 'div',
279
+ { className: 'dp-row', role: 'radiogroup', 'aria-label': t('levelGroup') },
280
+ LEVELS.map((level) => React.createElement(
281
+ 'button',
282
+ {
283
+ key: level.value,
284
+ type: 'button',
285
+ role: 'radio',
286
+ 'aria-checked': current === level.value,
287
+ disabled,
288
+ className: `dp-pill${current === level.value ? ' dp-pill-selected' : ''}`,
289
+ onClick: () => { write(() => scope.set('defaultMode', level.value)) },
290
+ },
291
+ t(level.label),
292
+ )),
293
+ ),
294
+ selected === undefined ? null : React.createElement('div', { className: 'dp-hint' }, t(selected.hint)),
295
+ sessionOnly ? React.createElement('div', { className: 'dp-hint' }, t('sourceSession')) : null,
296
+ React.createElement(
297
+ 'div',
298
+ { className: 'dp-status' },
299
+ snapshot.writable ? t('persists') : t('readOnly'),
300
+ overridden
301
+ ? React.createElement(
302
+ 'button',
303
+ {
304
+ type: 'button',
305
+ className: 'dp-reset',
306
+ disabled,
307
+ onClick: () => { write(() => scope.unset('defaultMode')) },
308
+ },
309
+ t('reset'),
310
+ )
311
+ : null,
312
+ ),
313
+ error === null ? null : React.createElement('div', { className: 'dp-error' }, error),
314
+ ),
315
+ )
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Build the read-only level chip for the composer tool row.
321
+ *
322
+ * It reads the same settings scope the card edits, so the two surfaces can
323
+ * never disagree; it renders nothing while the namespace is unavailable or
324
+ * the level is `off`, where a chip would only say "nothing is injected".
325
+ * @param scope - the scope bound to the ponytail namespace.
326
+ * @param t - translate function bound to this plugin's locale namespace.
327
+ * @returns the component the composer slot renders.
328
+ */
329
+ function createIndicator(scope, t) {
330
+ const usePonytail = useScope(scope)
331
+
332
+ return function PonytailIndicator() {
333
+ const snapshot = usePonytail()
334
+ const current = shownMode(snapshot, useHostLevel(snapshot))
335
+ if (current === undefined || current === 'off') return null
336
+
337
+ const text = t('indicator', { level: levelLabel(t, current) })
338
+ return React.createElement('span', { className: 'dp-chip', title: text }, text)
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Mount both browser surfaces: the Plugins card and the composer chip.
344
+ * @param ctx - the browser plugin context.
345
+ */
346
+ function apply(ctx) {
347
+ const t = ctx.locale.bind(LOCALE_NS)
348
+ ctx.effect(
349
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
350
+ 'dsh-ponytail: locale dictionary',
351
+ )
352
+
353
+ const scope = ctx.configForms.get(NAMESPACE)
354
+ const Card = createCard(scope, t)
355
+ const Indicator = createIndicator(scope, t)
356
+
357
+ // Each owner declares its own slot; injecting waits for it to exist, so
358
+ // these registrations do not depend on plugin load order.
359
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
360
+ name: 'plugins.row.config',
361
+ key: '@maci0/dsh-ponytail#ponytail',
362
+ locale: LOCALE_NS,
363
+ }, Card))
364
+
365
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
366
+ name: 'conversation.input.left',
367
+ id: 'ponytail-level',
368
+ order: 20,
369
+ }, Indicator))
370
+ }
371
+
372
+ exports.apply = apply
373
+ exports.inject = ['slots', 'configForms', 'locale']
374
+ return module.exports
375
+ },
376
+ })