dsh-output-styles 0.2.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.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.es.md +197 -0
  3. package/README.ja.md +197 -0
  4. package/README.ko.md +197 -0
  5. package/README.md +197 -0
  6. package/README.zh.md +197 -0
  7. package/cordis.patch.yml +40 -0
  8. package/docs/VERIFICATION.zh.md +93 -0
  9. package/lib/client.js +83 -0
  10. package/lib/index.js +415 -0
  11. package/lib/invariant-LV6hQX5s.js +442 -0
  12. package/lib/invariant.js +2 -0
  13. package/lib/types/client/index.d.ts +30 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/locales.d.ts +13 -0
  16. package/lib/types/client/locales.d.ts.map +1 -0
  17. package/lib/types/config.d.ts +73 -0
  18. package/lib/types/config.d.ts.map +1 -0
  19. package/lib/types/index.d.ts +31 -0
  20. package/lib/types/index.d.ts.map +1 -0
  21. package/lib/types/invariant.d.ts +59 -0
  22. package/lib/types/invariant.d.ts.map +1 -0
  23. package/lib/types/runtime.d.ts +144 -0
  24. package/lib/types/runtime.d.ts.map +1 -0
  25. package/lib/types/style-command.d.ts +68 -0
  26. package/lib/types/style-command.d.ts.map +1 -0
  27. package/lib/types/style-library.d.ts +78 -0
  28. package/lib/types/style-library.d.ts.map +1 -0
  29. package/lib/types/types.d.ts +78 -0
  30. package/lib/types/types.d.ts.map +1 -0
  31. package/package.json +138 -0
  32. package/src/client/index.ts +104 -0
  33. package/src/client/locales.ts +14 -0
  34. package/src/config.ts +110 -0
  35. package/src/index.ts +51 -0
  36. package/src/invariant.ts +144 -0
  37. package/src/runtime.ts +439 -0
  38. package/src/style-command.ts +89 -0
  39. package/src/style-library.ts +348 -0
  40. package/src/types.ts +86 -0
  41. package/styles/concise.md +17 -0
  42. package/styles/explanatory.md +14 -0
  43. package/styles/formal.md +14 -0
  44. package/styles/step-by-step.md +16 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Runtime boundary and Cordis activation: style resolution over the durable
3
+ * selection domain, the model-visible system-prompt section, the `/style`
4
+ * command, the `style` session projection, and the invariant registration.
5
+ *
6
+ * Every registration is an effect — Cordis undoes all of them on unload, so
7
+ * configuration hot-reload replaces the whole plugin without residue.
8
+ * @module dsh-output-styles/runtime
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url'
12
+ import { watch, type FSWatcher } from 'node:fs'
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type { Session, SessionId } from '@deepseek-ai/dsh-session'
15
+ import type {} from '@deepseek-ai/dsh-agent'
16
+ import type {} from '@deepseek-ai/dsh-commands'
17
+ import type {} from '@deepseek-ai/dsh-session-projection'
18
+ import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
19
+ import type { Domain, DomainFacility, KvTable } from '@deepseek-ai/dsh-storage-domain'
20
+ import z from '@deepseek-ai/schemastery'
21
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
22
+ import { resolveConfig, type Config } from './config.ts'
23
+ import { installInvariant, PACKAGE_NAME, type InvariantFacts, type InvariantRegistry } from './invariant.ts'
24
+ import { loadStyleLibrary, truncateStyle, type OutputStyle } from './style-library.ts'
25
+ import { applyStyleEvent, EMPTY_STYLE_STATE, parseStyleInput, STYLE_COMMAND, type StyleFoldState } from './style-command.ts'
26
+ import { OUTPUT_STYLE_DOMAIN, STYLE_SOURCE, styleSelectionViewSchema, type StyleSelection, type StyleSelectionView } from './types.ts'
27
+
28
+ /** Bundled style-library directory (package `styles/`), the lowest-priority `stylesDir` entry. */
29
+ export const DEFAULT_STYLES_DIR = fileURLToPath(new URL('../styles/', import.meta.url))
30
+
31
+ /** Prompt-section name; a fixed registry key a scoped composition could shadow. */
32
+ export const STYLE_SECTION_NAME = 'output-style:selection'
33
+
34
+ /** Settings namespace owning the project-level default (`outputStyle`). */
35
+ const SETTINGS_NS = settingsNamespace('output-style')
36
+
37
+ /** Coalescing delay for style-file change events; an internal implementation constant, not a deployment knob. */
38
+ const WATCH_DEBOUNCE_MS = 250
39
+
40
+ /**
41
+ * Resolved style behavior for one session. A forced style (frontmatter
42
+ * `force: true`) wins over everything; otherwise the session's own durable
43
+ * selection wins, sessions that never selected one fall back to the project
44
+ * default (settings `outputStyle`, then the configured default style), and
45
+ * `''` means no style at all.
46
+ */
47
+ export class OutputStyleRuntime {
48
+ private library: ReadonlyMap<string, OutputStyle>
49
+ private forcedStyle: OutputStyle | undefined
50
+
51
+ private readonly selection: KvTable<SessionId, StyleSelection>
52
+ private readonly defaultStyle: string
53
+ private readonly maxStyleChars: number
54
+ private readonly truncationMarker: string
55
+ private projectDefault: () => string
56
+
57
+ /** Style library in deterministic directory/file order. */
58
+ get styles(): ReadonlyMap<string, OutputStyle> {
59
+ return this.library
60
+ }
61
+
62
+ /**
63
+ * @param domain - the opened `output_style` domain; the caller owns `close()`.
64
+ * @param styles - the loaded style library (at most one `force` style).
65
+ * @param options - resolved style budget and default.
66
+ */
67
+ constructor(
68
+ private readonly domain: Domain<typeof OUTPUT_STYLE_DOMAIN>,
69
+ styles: ReadonlyMap<string, OutputStyle>,
70
+ options: {
71
+ readonly defaultStyle: string
72
+ readonly maxStyleChars: number
73
+ readonly truncationMarker: string
74
+ },
75
+ ) {
76
+ this.library = styles
77
+ this.selection = domain.table('selection')
78
+ this.defaultStyle = options.defaultStyle
79
+ this.maxStyleChars = options.maxStyleChars
80
+ this.truncationMarker = options.truncationMarker
81
+ this.forcedStyle = [...styles.values()].find(style => style.force)
82
+ this.projectDefault = () => this.defaultStyle
83
+ }
84
+
85
+ /** Every switchable style name, in library order. */
86
+ get names(): readonly string[] {
87
+ return [...this.library.keys()]
88
+ }
89
+
90
+ /** The forced style's name, or undefined when the library declares none. */
91
+ get forcedName(): string | undefined {
92
+ return this.forcedStyle?.name
93
+ }
94
+
95
+ /**
96
+ * Atomically swap the style library (style-file hot reload). The forced
97
+ * style is recomputed; the caller already validated the new library.
98
+ * @param styles - the replacement library.
99
+ */
100
+ reload(styles: ReadonlyMap<string, OutputStyle>): void {
101
+ this.library = styles
102
+ this.forcedStyle = [...styles.values()].find(style => style.force)
103
+ }
104
+
105
+ /**
106
+ * Point the project-default resolution at a live source (the settings
107
+ * scope while one is attached, the composition entry otherwise).
108
+ * @param get - thunk returning the project default style name (`''` = none).
109
+ */
110
+ setProjectDefault(get: () => string): void {
111
+ this.projectDefault = get
112
+ }
113
+
114
+ /**
115
+ * Resolve one style by name.
116
+ * @param name - style name.
117
+ * @returns the style, or undefined when the library has none.
118
+ */
119
+ get(name: string): OutputStyle | undefined {
120
+ return this.library.get(name)
121
+ }
122
+
123
+ /**
124
+ * The session's durable selection record.
125
+ * @param sessionId - the session the selection belongs to.
126
+ * @returns the record, or undefined when the session never selected one.
127
+ */
128
+ selectionFor(sessionId: SessionId): StyleSelection | undefined {
129
+ return this.selection.get(sessionId)
130
+ }
131
+
132
+ /**
133
+ * The style in force for a session: a forced library style, else the
134
+ * session's own selection, else the project default, else none. A stale
135
+ * selection or project default (its style left the library) also degrades
136
+ * to none.
137
+ * @param sessionId - the session the style is resolved for.
138
+ * @returns the effective style, or undefined when no style applies.
139
+ */
140
+ effectiveStyle(sessionId: SessionId): OutputStyle | undefined {
141
+ if (this.forcedStyle !== undefined) return this.forcedStyle
142
+ const record = this.selectionFor(sessionId)
143
+ const name = record !== undefined ? record.style : this.projectDefault()
144
+ return name === '' ? undefined : this.library.get(name)
145
+ }
146
+
147
+ /**
148
+ * The effective style name, or `''` when no style applies.
149
+ * @param sessionId - the session the style is resolved for.
150
+ * @returns the style name, or `''`.
151
+ */
152
+ currentName(sessionId: SessionId): string {
153
+ return this.effectiveStyle(sessionId)?.name ?? ''
154
+ }
155
+
156
+ /**
157
+ * The model-visible style directive for a session: a header naming the
158
+ * style plus its body under the configured budget. The exact text is what
159
+ * the harness logs in `request/header` before dispatch.
160
+ * @param sessionId - the session the directive is built for.
161
+ * @returns the directive, or `''` when no style applies.
162
+ */
163
+ promptText(sessionId: SessionId): string {
164
+ const style = this.effectiveStyle(sessionId)
165
+ if (style === undefined) return ''
166
+ const body = truncateStyle(style.body, this.maxStyleChars, this.truncationMarker)
167
+ return `# Output style: ${style.name}\n\nUse the following output style for every response in this conversation:\n\n${body}`
168
+ }
169
+
170
+ /**
171
+ * The `/style` no-argument listing: the current selection followed by one
172
+ * line per style (`name — description`, with `whenToUse` appended when set).
173
+ * @param sessionId - the session the listing describes.
174
+ * @returns the multi-line command result text.
175
+ */
176
+ listLine(sessionId: SessionId): string {
177
+ const current = this.currentName(sessionId)
178
+ const lines = [current === '' ? 'output style off' : `current output style: ${current}`]
179
+ for (const style of this.styles.values()) {
180
+ const whenToUse = style.whenToUse === undefined ? '' : ` (${style.whenToUse})`
181
+ lines.push(`${style.name} — ${style.description}${whenToUse}`)
182
+ }
183
+ return lines.join('\n')
184
+ }
185
+
186
+ /**
187
+ * The unknown-name error line, shared by the command handler and the direct
188
+ * {@link OutputStyleRuntime.select} write path.
189
+ * @param name - the rejected switch target.
190
+ * @returns the error text listing every switchable name.
191
+ */
192
+ unknownStyleLine(name: string): string {
193
+ return `unknown output style "${name}" (available: ${this.names.join(', ')})`
194
+ }
195
+
196
+ /**
197
+ * Durably select a style for a session. The write resolves only after the
198
+ * backend acknowledged it, so a settled command implies a stored record.
199
+ * @param session - the session the selection belongs to.
200
+ * @param name - library style name; unknown names throw.
201
+ * @returns resolution after durability.
202
+ */
203
+ async select(session: Session, name: string): Promise<void> {
204
+ if (this.styles.get(name) === undefined) {
205
+ throw new Error(`dsh-output-styles: ${this.unknownStyleLine(name)}`)
206
+ }
207
+ await this.selection.put(session.id, { style: name, source: STYLE_SOURCE })
208
+ }
209
+
210
+ /**
211
+ * Remove a session's selection, restoring the configured default.
212
+ * @param session - the session whose selection is removed.
213
+ * @returns resolution after durability.
214
+ */
215
+ async turnOff(session: Session): Promise<void> {
216
+ await this.selection.delete(session.id)
217
+ }
218
+
219
+ /**
220
+ * Close the opened domain (the plugin fiber's async disposer).
221
+ * @returns resolution after the backend unit is released.
222
+ */
223
+ async close(): Promise<void> {
224
+ await this.domain.close()
225
+ }
226
+ }
227
+
228
+ /** Build the `style` projection's wire value for one folded state. */
229
+ function viewStyleSelection(runtime: OutputStyleRuntime, state: StyleFoldState): StyleSelectionView {
230
+ // The library-membership guard covers a settled selection whose style later
231
+ // left the library: the view degrades to "no selection" until the session
232
+ // switches again.
233
+ const currentValue = state.current !== null && runtime.styles.has(state.current) ? state.current : null
234
+ return {
235
+ options: [...runtime.styles.entries()].map(([value, style]) => ({
236
+ value,
237
+ name: style.name,
238
+ description: style.description,
239
+ ...style.whenToUse === undefined ? {} : { whenToUse: style.whenToUse },
240
+ })),
241
+ currentValue,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Apply the plugin to its Cordis context.
247
+ *
248
+ * Declares `storageDomain` via `inject` (ready before `apply`; a composition
249
+ * without a routed kv backend keeps the plugin pending). Bad style files are
250
+ * skipped with warnings; a duplicate or reserved style name, an unreadable
251
+ * style directory, or a `defaultStyle` naming no style fails the load.
252
+ * @param ctx - scoped plugin context; registrations must be owned by its effects.
253
+ * @param config - configuration resolved by Cordis from the exported schema.
254
+ */
255
+ export async function apply(ctx: Context, config: Config): Promise<void> {
256
+ // Defense for direct (non-Loader) callers: the declared inject makes this
257
+ // present in any composition, so the guard only reports programmer error.
258
+ const storageDomain = ctx.get('storageDomain') as DomainFacility | undefined
259
+ if (storageDomain === undefined) {
260
+ throw new Error(
261
+ 'dsh-output-styles: the "storageDomain" service declared by inject is unavailable — '
262
+ + 'mount the storage facility (see README) or declare the inject',
263
+ )
264
+ }
265
+ const resolved = resolveConfig(config, DEFAULT_STYLES_DIR)
266
+ const styles = loadStyleLibrary(
267
+ resolved.stylesDirs,
268
+ { compatJson: resolved.compatJson },
269
+ message => { ctx.logger.warn(`dsh-output-styles: ${message}`) },
270
+ )
271
+ if (resolved.defaultStyle !== '' && !styles.has(resolved.defaultStyle)) {
272
+ throw new Error(
273
+ `dsh-output-styles: defaultStyle "${resolved.defaultStyle}" names no style in ${resolved.stylesDirs.join(', ')} `
274
+ + `(available: ${[...styles.keys()].join(', ') || 'none'})`,
275
+ )
276
+ }
277
+ const domain = await storageDomain.open(OUTPUT_STYLE_DOMAIN)
278
+ ctx.effect(() => () => domain.close())
279
+ const runtime = new OutputStyleRuntime(domain, styles, resolved)
280
+
281
+ // Style-file hot reload: watch every library directory and atomically swap
282
+ // the runtime library after a coalescing delay. A reload that would break
283
+ // the configured default or otherwise fail keeps the previous library.
284
+ if (resolved.watchStyles) {
285
+ let timer: NodeJS.Timeout | undefined
286
+ const reload = (): void => {
287
+ try {
288
+ const next = loadStyleLibrary(
289
+ resolved.stylesDirs,
290
+ { compatJson: resolved.compatJson },
291
+ message => { ctx.logger.warn(`dsh-output-styles: ${message}`) },
292
+ )
293
+ if (resolved.defaultStyle !== '' && !next.has(resolved.defaultStyle)) {
294
+ ctx.logger.warn(`dsh-output-styles: style file change removed defaultStyle "${resolved.defaultStyle}"; keeping the previous library`)
295
+ return
296
+ }
297
+ runtime.reload(next)
298
+ } catch (error) {
299
+ ctx.logger.warn(`dsh-output-styles: style file change not applied: ${error instanceof Error ? error.message : String(error)}`)
300
+ }
301
+ }
302
+ const schedule = (): void => {
303
+ if (timer !== undefined) return
304
+ timer = setTimeout(() => {
305
+ timer = undefined
306
+ reload()
307
+ }, WATCH_DEBOUNCE_MS)
308
+ }
309
+ ctx.effect(() => {
310
+ const watchers: FSWatcher[] = []
311
+ for (const dir of resolved.stylesDirs) {
312
+ try {
313
+ watchers.push(watch(dir, { persistent: false }, () => { schedule() }))
314
+ } catch (error) {
315
+ ctx.logger.warn(`dsh-output-styles: cannot watch style directory ${dir}: ${error instanceof Error ? error.message : String(error)}`)
316
+ }
317
+ }
318
+ return () => {
319
+ if (timer !== undefined) clearTimeout(timer)
320
+ for (const watcher of watchers) watcher.close()
321
+ }
322
+ })
323
+ }
324
+
325
+ // Project-level default over the settings seam: sessions that never
326
+ // selected a style fall back to `output-style.style` (user settings layer,
327
+ // then the composition defaultStyle). Stays inactive until a settings
328
+ // provider is composed; the settings namespace validates names against the
329
+ // live library at write time.
330
+ installSettingsSection(
331
+ ctx,
332
+ SETTINGS_NS,
333
+ z.object({ style: z.string().default('') }),
334
+ { style: resolved.defaultStyle },
335
+ {
336
+ setSource: current => { runtime.setProjectDefault(() => current().style) },
337
+ onChange: () => {},
338
+ validate: value => {
339
+ if (value.style !== '' && !runtime.styles.has(value.style)) {
340
+ throw new Error(
341
+ `dsh-output-styles: settings outputStyle "${value.style}" names no style `
342
+ + `(available: ${[...runtime.styles.keys()].join(', ') || 'none'})`,
343
+ )
344
+ }
345
+ },
346
+ },
347
+ )
348
+
349
+ ctx.systemPrompt.section({
350
+ name: STYLE_SECTION_NAME,
351
+ order: resolved.sectionOrder,
352
+ text: (context: AssembleContext) => {
353
+ const agent = context.agent
354
+ if (agent === undefined) return ''
355
+ const style = runtime.effectiveStyle(agent.session.id)
356
+ // A keep-coding-instructions: false style owns the whole prompt: the
357
+ // assembly waterfall below rebuilds the section list, so this section
358
+ // stays silent instead of double-injecting.
359
+ if (style === undefined || !style.keepCodingInstructions) return ''
360
+ return runtime.promptText(agent.session.id)
361
+ },
362
+ })
363
+
364
+ // Claude Code keep-coding-instructions: false — the active style replaces
365
+ // the whole system prompt. The waterfall runs after downstream
366
+ // contributions resolve (tools, contexts, and variables still assemble),
367
+ // then swaps in a single style section.
368
+ ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context: AssembleContext, next) => {
369
+ const out = await next()
370
+ const agent = context.agent
371
+ if (agent === undefined) return out
372
+ const style = runtime.effectiveStyle(agent.session.id)
373
+ if (style === undefined || style.keepCodingInstructions) return out
374
+ return {
375
+ ...out,
376
+ sections: [{ name: STYLE_SECTION_NAME, text: runtime.promptText(agent.session.id) }],
377
+ }
378
+ })
379
+
380
+ // The /style command: the one write path a web client uses. The child
381
+ // activates only when a command registry is composed (headless assemblies
382
+ // without it stay unaffected). The registry logs `command/run` with the
383
+ // verbatim input, so every switch is reconstructable from the session log.
384
+ ctx.inject(['commands'], (commandCtx) => {
385
+ commandCtx.commands.register({
386
+ name: STYLE_COMMAND,
387
+ description: 'Switch the model output style for this session',
388
+ input: { hint: '<style | off>' },
389
+ handler: async ({ agent, rawInput }) => {
390
+ const input = parseStyleInput(rawInput)
391
+ if (input.kind === 'none') {
392
+ return { kind: 'success', text: runtime.listLine(agent.session.id) }
393
+ }
394
+ if (input.kind === 'off') {
395
+ await runtime.turnOff(agent.session)
396
+ const forced = runtime.forcedName
397
+ return {
398
+ kind: 'success',
399
+ text: forced === undefined
400
+ ? 'output style off'
401
+ : `output style off (style "${forced}" remains in force)`,
402
+ }
403
+ }
404
+ if (runtime.get(input.name) === undefined) {
405
+ return { kind: 'error', text: runtime.unknownStyleLine(input.name) }
406
+ }
407
+ await runtime.select(agent.session, input.name)
408
+ return { kind: 'success', text: `switched to ${input.name}` }
409
+ },
410
+ })
411
+ })
412
+
413
+ // The `style` session projection: folds accepted `/style` switches off the
414
+ // session log so the Web UI can show the current style without reading the
415
+ // domain. Activates only when a projection registry is composed.
416
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
417
+ projectionCtx.sessionProjections.register<'style', StyleFoldState>({
418
+ key: 'style',
419
+ schema: styleSelectionViewSchema,
420
+ init: () => EMPTY_STYLE_STATE,
421
+ apply: applyStyleEvent,
422
+ view: state => viewStyleSelection(runtime, state),
423
+ stateVersion: 2,
424
+ })
425
+ })
426
+
427
+ // The invariant companion, registered from the main plugin so its checks
428
+ // see the live library and domain. Activates only when an invariant
429
+ // registry is composed.
430
+ ctx.inject(['invariants'], (invariantCtx) => {
431
+ const registry = invariantCtx.get('invariants') as InvariantRegistry | undefined
432
+ if (registry === undefined) return
433
+ const facts: InvariantFacts = {
434
+ knownStyles: () => new Set(runtime.names),
435
+ selectionFor: sessionId => runtime.selectionFor(sessionId),
436
+ }
437
+ registry.register(PACKAGE_NAME, installInvariant(facts))
438
+ })
439
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Strict parsing of the `/style` command input and the pure projection fold
3
+ * over its logged lifecycle events.
4
+ *
5
+ * The handler and the session-projection unit share {@link parseStyleInput}:
6
+ * the projection folds exactly the inputs the handler accepts, so the
7
+ * displayed selection can never diverge from what the command did.
8
+ * @module dsh-output-styles/style-command
9
+ */
10
+
11
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
12
+ import type {} from '@deepseek-ai/dsh-commands'
13
+ import { OFF } from './types.ts'
14
+
15
+ /** Command name registered on `ctx.commands`; also the log's `command/run` name. */
16
+ export const STYLE_COMMAND = 'style'
17
+
18
+ /** One strict parse of a `/style` input line. */
19
+ export type StyleInput =
20
+ | { kind: 'none' }
21
+ | { kind: 'off' }
22
+ | { kind: 'switch'; name: string }
23
+
24
+ /**
25
+ * Parse the text after `/style` into one switch decision. The empty string
26
+ * is `none` — the handler treats it as the listing form, and the projection
27
+ * fold ignores it. Anything else is a switch: `off` restores the default,
28
+ * and every other input is one style name taken verbatim (style names may
29
+ * contain spaces, so the whole remainder is the candidate). Unknown names
30
+ * are rejected by the handler against the library, and the fold commits only
31
+ * what the handler reports as successful, so both sides agree on what
32
+ * actually switched.
33
+ * @param rawInput - verbatim text after the command name.
34
+ * @returns the decision; non-empty inputs other than `off` name a style.
35
+ */
36
+ export function parseStyleInput(rawInput: string): StyleInput {
37
+ const arg = rawInput.trim()
38
+ if (arg === '') return { kind: 'none' }
39
+ if (arg === OFF) return { kind: 'off' }
40
+ return { kind: 'switch', name: arg }
41
+ }
42
+
43
+ /** Projection-fold state: the last settled selection plus the in-flight switch. */
44
+ export interface StyleFoldState {
45
+ /** Accepted switch target that settled successfully, or null when the session's style was turned off. */
46
+ current: string | null
47
+ /**
48
+ * One `/style` run that entered its handler but has not settled yet: its
49
+ * `command/run` claimed this slot and only its paired `command/done`
50
+ * resolves it. A failed or aborted run drops the target without touching
51
+ * `current`, so the folded state always mirrors what the write path actually
52
+ * committed.
53
+ */
54
+ pending: { commandId: string; target: { name: string } | { off: true } } | null
55
+ }
56
+
57
+ /** State for the empty log. */
58
+ export const EMPTY_STYLE_STATE: StyleFoldState = { current: null, pending: null }
59
+
60
+ /**
61
+ * One-event transition of the `style` projection unit. Only a successful
62
+ * `/style` command settles into `current`: `command/run` parks the target as
63
+ * `pending` and its paired `command/done` commits it on `kind: 'success'` or
64
+ * drops it otherwise. Every other event returns the same reference (the
65
+ * registry's change gate).
66
+ * @param state - the folded state before `event`.
67
+ * @param event - one committed session event.
68
+ * @returns the next state; the same reference when the event leaves it unchanged.
69
+ */
70
+ export function applyStyleEvent(state: StyleFoldState, event: SessionEvent): StyleFoldState {
71
+ if (event.type === 'command/run') {
72
+ if (event.data.name !== STYLE_COMMAND || event.data.args === undefined) return state
73
+ const input = parseStyleInput(event.data.args)
74
+ if (input.kind === 'none') return state
75
+ const commandId = String(event.data.commandId)
76
+ if (state.pending?.commandId === commandId) return state
77
+ const target: { name: string } | { off: true } = input.kind === 'off' ? { off: true } : { name: input.name }
78
+ return { ...state, pending: { commandId, target } }
79
+ }
80
+ if (event.type !== 'command/done' || state.pending === null) return state
81
+ if (state.pending.commandId !== String(event.data.commandId)) return state
82
+ if (event.data.kind !== 'success') {
83
+ // The handler reported failure or was aborted: the selection record was
84
+ // not written (or the off-delete did not land), so current stays put.
85
+ return { current: state.current, pending: null }
86
+ }
87
+ const current = 'off' in state.pending.target ? null : state.pending.target.name
88
+ return { current, pending: null }
89
+ }