dsh-output-styles 0.3.2 → 0.4.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/CHANGELOG.md +15 -0
- package/README.es.md +15 -0
- package/README.ja.md +15 -0
- package/README.ko.md +15 -0
- package/README.md +63 -1
- package/README.zh.md +37 -0
- package/docs/renderer-protocol.md +134 -0
- package/docs/renderer-protocol.zh.md +125 -0
- package/lib/index.js +434 -3
- package/lib/types/config.d.ts +32 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/export.d.ts +76 -0
- package/lib/types/export.d.ts.map +1 -0
- package/lib/types/renderers.d.ts +119 -0
- package/lib/types/renderers.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +11 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/types.d.ts +32 -0
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +49 -0
- package/src/export.ts +199 -0
- package/src/renderers.ts +264 -0
- package/src/runtime.ts +110 -0
- package/src/types.ts +28 -0
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `output.render.*` protocol: a presenter registry that turns raw
|
|
3
|
+
* model-visible text into display text. A renderer is a pure function —
|
|
4
|
+
* `presenter(text, meta)` maps args to presentation data and never touches
|
|
5
|
+
* the DOM — matched by tool name and content type, ordered by priority.
|
|
6
|
+
* Every rendered result carries its original text alongside, so any consumer
|
|
7
|
+
* (this plugin's `/export`, third-party panels) can log both and keep the
|
|
8
|
+
* "model-visible ⟺ reconstructable" invariant.
|
|
9
|
+
*
|
|
10
|
+
* This module is dependency-free (no DOM, no node: imports, no DSH imports)
|
|
11
|
+
* so the protocol vocabulary itself is portable and unit-testable anywhere.
|
|
12
|
+
* @module dsh-output-styles/renderers
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Content-type vocabulary a renderer can match on. */
|
|
16
|
+
export type ContentType = 'text' | 'markdown' | 'html'
|
|
17
|
+
|
|
18
|
+
/** Match rule deciding whether a renderer applies to one render request. */
|
|
19
|
+
export interface RendererMatch {
|
|
20
|
+
/** Tool names the renderer applies to ('*' = every tool); omitted = match all. */
|
|
21
|
+
readonly tool?: string | readonly string[]
|
|
22
|
+
/** Content types the renderer applies to; omitted = match all. */
|
|
23
|
+
readonly contentType?: ContentType | readonly ContentType[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Facts a render request carries (structural; the DOM never reaches this layer). */
|
|
27
|
+
export interface RenderContext {
|
|
28
|
+
/** The tool that produced the text, or '' for assistant/user prose. */
|
|
29
|
+
readonly tool: string
|
|
30
|
+
/** The declared content type of the text ('text' when unknown). */
|
|
31
|
+
readonly contentType: ContentType
|
|
32
|
+
/** The session the text belongs to (per-session rules match on it). */
|
|
33
|
+
readonly sessionId?: string
|
|
34
|
+
/** Session-scoped extras a presenter may read (workspace title etc.). */
|
|
35
|
+
readonly meta?: Readonly<Record<string, string>>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The renderer contract a third-party plugin registers. */
|
|
39
|
+
export interface OutputRenderer {
|
|
40
|
+
/** Unique renderer id (kebab-case); the rule field and `/export --renderer` name it. */
|
|
41
|
+
readonly id: string
|
|
42
|
+
/** Human-readable name. */
|
|
43
|
+
readonly name: string
|
|
44
|
+
/** One sentence on what the presenter does. */
|
|
45
|
+
readonly description: string
|
|
46
|
+
/** Applicability rules; an empty array matches everything. */
|
|
47
|
+
readonly match: readonly RendererMatch[]
|
|
48
|
+
/** Higher priority wins; ties break by registration order (earlier first). */
|
|
49
|
+
readonly priority: number
|
|
50
|
+
/** Pure presentation function: args in, display data out. */
|
|
51
|
+
readonly presenter: (text: string, context: RenderContext) => string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The auditable render result: original and rendered travel together. */
|
|
55
|
+
export interface RenderedText {
|
|
56
|
+
/** The original model-visible text (never mutated). */
|
|
57
|
+
readonly original: string
|
|
58
|
+
/** The presented text (equal to the original when nothing matched). */
|
|
59
|
+
readonly rendered: string
|
|
60
|
+
/** The renderer that applied, or undefined when no renderer matched. */
|
|
61
|
+
readonly rendererId?: string
|
|
62
|
+
/** Whether the presentation changed the text. */
|
|
63
|
+
readonly changed: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One per-session/per-tool style rule from the configuration. */
|
|
67
|
+
export interface StyleRule {
|
|
68
|
+
/** Match facts; an empty object matches everything. */
|
|
69
|
+
readonly match: {
|
|
70
|
+
/** Tool name or '*' (omitted = any tool). */
|
|
71
|
+
readonly tool?: string
|
|
72
|
+
/** Content type (omitted = any). */
|
|
73
|
+
readonly contentType?: ContentType
|
|
74
|
+
/** Exact session id (omitted = any session) — the per-session axis. */
|
|
75
|
+
readonly session?: string
|
|
76
|
+
}
|
|
77
|
+
/** Renderer id to apply (built-ins mirror the style names: concise, step-by-step). */
|
|
78
|
+
readonly style: string
|
|
79
|
+
/** Higher priority wins; ties break by rule order (earlier first). */
|
|
80
|
+
readonly priority: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Registry handle returned by register(). */
|
|
84
|
+
export type RendererDisposer = () => void
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validates a renderer before registration: id grammar, name, description,
|
|
88
|
+
* match shape, priority, presenter function. Throws on the first violation
|
|
89
|
+
* (fail-loud — a bad renderer never sits in the registry).
|
|
90
|
+
* @param renderer - candidate renderer.
|
|
91
|
+
*/
|
|
92
|
+
export function validateRenderer(renderer: OutputRenderer): void {
|
|
93
|
+
if (typeof renderer !== 'object' || renderer === null) throw new Error('renderer must be an object')
|
|
94
|
+
if (typeof renderer.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(renderer.id)) {
|
|
95
|
+
throw new Error(`invalid renderer id ${JSON.stringify(renderer.id)}: use kebab-case`)
|
|
96
|
+
}
|
|
97
|
+
if (typeof renderer.name !== 'string' || renderer.name === '') throw new Error('renderer name must be a non-empty string')
|
|
98
|
+
if (typeof renderer.description !== 'string' || renderer.description === '') throw new Error('renderer description must be a non-empty string')
|
|
99
|
+
if (!Array.isArray(renderer.match)) throw new Error('renderer match must be an array')
|
|
100
|
+
for (const match of renderer.match) {
|
|
101
|
+
if (match.tool !== undefined) {
|
|
102
|
+
const tools = Array.isArray(match.tool) ? match.tool : [match.tool]
|
|
103
|
+
if (!tools.every((item: unknown) => typeof item === 'string')) {
|
|
104
|
+
throw new Error('renderer match.tool must be a string or string array')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (match.contentType !== undefined) {
|
|
108
|
+
const types = Array.isArray(match.contentType) ? match.contentType : [match.contentType]
|
|
109
|
+
if (!types.every((item: unknown) => typeof item === 'string')) {
|
|
110
|
+
throw new Error('renderer match.contentType must be a string or string array')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (typeof renderer.priority !== 'number' || !Number.isFinite(renderer.priority)) {
|
|
115
|
+
throw new Error('renderer priority must be a finite number')
|
|
116
|
+
}
|
|
117
|
+
if (typeof renderer.presenter !== 'function') throw new Error('renderer presenter must be a function')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Match one rule fact (a string, a string list, or undefined=any) against a value. */
|
|
121
|
+
function factMatches(fact: string | readonly string[] | undefined, value: string): boolean {
|
|
122
|
+
if (fact === undefined) return true
|
|
123
|
+
if (typeof fact === 'string') return fact === '*' || fact === value
|
|
124
|
+
return fact.includes('*') || fact.includes(value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether a renderer matches a render request. */
|
|
128
|
+
function rendererMatches(renderer: OutputRenderer, context: RenderContext): boolean {
|
|
129
|
+
if (renderer.match.length === 0) return true
|
|
130
|
+
return renderer.match.some(match =>
|
|
131
|
+
factMatches(match.tool, context.tool)
|
|
132
|
+
&& factMatches(match.contentType, context.contentType))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a configured rule matches a render request. */
|
|
136
|
+
function ruleMatches(rule: StyleRule, context: RenderContext): boolean {
|
|
137
|
+
if (rule.match.tool !== undefined && rule.match.tool !== '*' && rule.match.tool !== context.tool) return false
|
|
138
|
+
if (rule.match.contentType !== undefined && rule.match.contentType !== context.contentType) return false
|
|
139
|
+
if (rule.match.session !== undefined && rule.match.session !== context.sessionId) return false
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The renderer registry: reversible registration, ordered resolution, and
|
|
145
|
+
* rule-driven rendering. Registration is a caller-owned effect (the runtime
|
|
146
|
+
* hands register()'s disposer to ctx.effect); the registry itself is pure
|
|
147
|
+
* state with no timers, listeners, or I/O.
|
|
148
|
+
*/
|
|
149
|
+
export class RendererRegistry {
|
|
150
|
+
private readonly renderers = new Map<string, { renderer: OutputRenderer; order: number }>()
|
|
151
|
+
private order = 0
|
|
152
|
+
|
|
153
|
+
/** Register a renderer; the disposer removes exactly this registration. */
|
|
154
|
+
register(renderer: OutputRenderer): RendererDisposer {
|
|
155
|
+
validateRenderer(renderer)
|
|
156
|
+
if (this.renderers.has(renderer.id)) {
|
|
157
|
+
throw new Error(`renderer ${JSON.stringify(renderer.id)} is already registered`)
|
|
158
|
+
}
|
|
159
|
+
const order = this.order++
|
|
160
|
+
this.renderers.set(renderer.id, { renderer, order })
|
|
161
|
+
return () => {
|
|
162
|
+
if (this.renderers.get(renderer.id)?.order === order) this.renderers.delete(renderer.id)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Every registered renderer, deterministic order (priority desc, registration asc). */
|
|
167
|
+
list(): OutputRenderer[] {
|
|
168
|
+
return [...this.renderers.values()]
|
|
169
|
+
.sort((a, b) => b.renderer.priority - a.renderer.priority || a.order - b.order)
|
|
170
|
+
.map(entry => entry.renderer)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolve the renderers that match a request, highest priority first. */
|
|
174
|
+
resolve(context: RenderContext): OutputRenderer[] {
|
|
175
|
+
return this.list().filter(renderer => rendererMatches(renderer, context))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render text through the rule table, then the matching renderer pipeline:
|
|
180
|
+
* the first matching rule names a renderer (applied alone, it is explicit),
|
|
181
|
+
* otherwise every matching renderer applies in priority order (each sees the
|
|
182
|
+
* previous renderer's output — composition, not competition).
|
|
183
|
+
* @param text - the raw model-visible text.
|
|
184
|
+
* @param context - tool / content-type / session facts.
|
|
185
|
+
* @param rules - configured style rules, highest priority first.
|
|
186
|
+
* @returns the auditable result (original always preserved).
|
|
187
|
+
*/
|
|
188
|
+
render(text: string, context: RenderContext, rules: readonly StyleRule[] = []): RenderedText {
|
|
189
|
+
const sortedRules = [...rules].sort((a, b) => b.priority - a.priority)
|
|
190
|
+
const hit = sortedRules.find(rule => ruleMatches(rule, context))
|
|
191
|
+
if (hit !== undefined) {
|
|
192
|
+
const renderer = this.renderers.get(hit.style)
|
|
193
|
+
if (renderer === undefined) {
|
|
194
|
+
throw new Error(`rule style ${JSON.stringify(hit.style)} names no registered renderer (available: ${this.list().map(item => item.id).join(', ') || 'none'})`)
|
|
195
|
+
}
|
|
196
|
+
const rendered = renderer.renderer.presenter(text, context)
|
|
197
|
+
return { original: text, rendered, rendererId: renderer.renderer.id, changed: rendered !== text }
|
|
198
|
+
}
|
|
199
|
+
let rendered = text
|
|
200
|
+
let rendererId: string | undefined
|
|
201
|
+
for (const renderer of this.resolve(context)) {
|
|
202
|
+
rendered = renderer.presenter(rendered, context)
|
|
203
|
+
rendererId = renderer.id
|
|
204
|
+
}
|
|
205
|
+
return { original: text, rendered, ...rendererId === undefined ? {} : { rendererId }, changed: rendered !== text }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Collapse whitespace runs and blank-line stacks; trim the ends. */
|
|
210
|
+
function compact(text: string, maxLines: number, maxChars: number, marker: string): string {
|
|
211
|
+
const collapsed = text
|
|
212
|
+
.split(/\r?\n/)
|
|
213
|
+
.map(line => line.replace(/[ \t]+/g, ' ').trimEnd())
|
|
214
|
+
.reduce((lines: string[], line) => {
|
|
215
|
+
if (line === '' && lines[lines.length - 1] === '') return lines
|
|
216
|
+
lines.push(line)
|
|
217
|
+
return lines
|
|
218
|
+
}, [])
|
|
219
|
+
.join('\n')
|
|
220
|
+
.trim()
|
|
221
|
+
let out = maxLines > 0 && collapsed.split('\n').length > maxLines
|
|
222
|
+
? collapsed.split('\n').slice(0, maxLines).join('\n') + marker
|
|
223
|
+
: collapsed
|
|
224
|
+
if (maxChars > 0 && out.length > maxChars) out = out.slice(0, maxChars) + marker
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Turn a list-shaped text into consistently numbered steps. */
|
|
229
|
+
function enumerate(text: string): string {
|
|
230
|
+
const lines = text.split(/\r?\n/)
|
|
231
|
+
const items = lines.filter(line => /^\s*(?:[-*•]|\d+[.)])\s+/.test(line))
|
|
232
|
+
if (items.length === 0) return text
|
|
233
|
+
let counter = 0
|
|
234
|
+
return lines.map((line) => {
|
|
235
|
+
if (!/^\s*(?:[-*•]|\d+[.)])\s+/.test(line)) return line
|
|
236
|
+
counter += 1
|
|
237
|
+
return line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, `${counter}. `)
|
|
238
|
+
}).join('\n')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The built-in style renderers, mirroring the two headline styles: `concise`
|
|
243
|
+
* compacts whitespace under a line/char budget, `step-by-step` numbers list
|
|
244
|
+
* items consistently. Their ids double as `/style`-compatible names in the
|
|
245
|
+
* rule table.
|
|
246
|
+
*/
|
|
247
|
+
export const BUILTIN_RENDERERS: readonly OutputRenderer[] = [
|
|
248
|
+
{
|
|
249
|
+
id: 'concise',
|
|
250
|
+
name: 'Concise',
|
|
251
|
+
description: 'Compacts whitespace runs and blank lines, and caps the presented text at a budget with a truncation marker.',
|
|
252
|
+
match: [],
|
|
253
|
+
priority: 10,
|
|
254
|
+
presenter: text => compact(text, 40, 4000, '\n\n[truncated]'),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'step-by-step',
|
|
258
|
+
name: 'Step-by-step',
|
|
259
|
+
description: 'Numbers list items (dashes, bullets, or digits) consistently from 1 so the presentation reads as ordered steps.',
|
|
260
|
+
match: [],
|
|
261
|
+
priority: 10,
|
|
262
|
+
presenter: text => enumerate(text),
|
|
263
|
+
},
|
|
264
|
+
]
|
package/src/runtime.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { installInvariant, PACKAGE_NAME, type InvariantFacts, type InvariantRegi
|
|
|
24
24
|
import { loadStyleLibrary, truncateStyle, type OutputStyle } from './style-library.ts'
|
|
25
25
|
import { applyStyleEvent, EMPTY_STYLE_STATE, parseStyleInput, STYLE_COMMAND, type StyleFoldState } from './style-command.ts'
|
|
26
26
|
import { OUTPUT_STYLE_DOMAIN, STYLE_SOURCE, styleSelectionViewSchema, type StyleSelection, type StyleSelectionView } from './types.ts'
|
|
27
|
+
import { BUILTIN_RENDERERS, RendererRegistry, type OutputRenderer, type RenderContext, type RenderedText, type StyleRule } from './renderers.ts'
|
|
28
|
+
import { conversationLines, renderExport } from './export.ts'
|
|
27
29
|
|
|
28
30
|
/** Bundled style-library directory (package `styles/`), the lowest-priority `stylesDir` entry. */
|
|
29
31
|
export const DEFAULT_STYLES_DIR = fileURLToPath(new URL('../styles/', import.meta.url))
|
|
@@ -436,4 +438,112 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
|
|
436
438
|
}
|
|
437
439
|
registry.register(PACKAGE_NAME, installInvariant(facts))
|
|
438
440
|
})
|
|
441
|
+
|
|
442
|
+
// ── output.render.* protocol: the renderer registry service ───────────────
|
|
443
|
+
// Third-party plugins register presenters (id / match rules / pure
|
|
444
|
+
// presenter / priority) through `ctx.outputRenderers`; registration is a
|
|
445
|
+
// caller-owned effect (register() returns the disposer). The built-in
|
|
446
|
+
// concise/step-by-step renderers mirror the two headline styles. Rendering
|
|
447
|
+
// runs the `output.render/before` waterfall first — listeners transform the
|
|
448
|
+
// request and MUST call next() — then applies the rule table and matching
|
|
449
|
+
// renderers; every result keeps the original text beside the rendered one.
|
|
450
|
+
const renderers = new RendererRegistry()
|
|
451
|
+
for (const renderer of BUILTIN_RENDERERS) {
|
|
452
|
+
ctx.effect(() => renderers.register(renderer), `dsh-output-styles: renderer ${renderer.id}`)
|
|
453
|
+
}
|
|
454
|
+
let effectiveRules: readonly StyleRule[] = resolved.rules
|
|
455
|
+
const renderText = (text: string, context: RenderContext): Promise<RenderedText> =>
|
|
456
|
+
ctx.waterfall('output.render/before', { text, context }, async (request: { text: string; context: RenderContext }) =>
|
|
457
|
+
renderers.render(request.text, request.context, effectiveRules))
|
|
458
|
+
const renderService = {
|
|
459
|
+
register: (renderer: OutputRenderer) => renderers.register(renderer),
|
|
460
|
+
list: () => renderers.list(),
|
|
461
|
+
resolve: (context: RenderContext) => renderers.resolve(context),
|
|
462
|
+
renderText,
|
|
463
|
+
}
|
|
464
|
+
ctx.provide('outputRenderers', renderService)
|
|
465
|
+
|
|
466
|
+
// Per-session/per-tool rules over the settings seam: the `output-style-rules`
|
|
467
|
+
// namespace carries the rule table (composition `base` + user overrides);
|
|
468
|
+
// rules referencing an unknown renderer fail at write time, and rendering
|
|
469
|
+
// fails loudly at call time if a renderer left the registry.
|
|
470
|
+
installSettingsSection(
|
|
471
|
+
ctx,
|
|
472
|
+
settingsNamespace('output-style-rules'),
|
|
473
|
+
z.object({
|
|
474
|
+
rules: z.array(z.object({
|
|
475
|
+
match: z.object({
|
|
476
|
+
tool: z.string().required(false),
|
|
477
|
+
contentType: z.union([z.const('text'), z.const('markdown'), z.const('html')]).required(false),
|
|
478
|
+
session: z.string().required(false),
|
|
479
|
+
}).required(false),
|
|
480
|
+
style: z.string().min(1),
|
|
481
|
+
priority: z.number().required(false),
|
|
482
|
+
})).default([]),
|
|
483
|
+
}),
|
|
484
|
+
{ rules: resolved.rules },
|
|
485
|
+
{
|
|
486
|
+
setSource: current => {
|
|
487
|
+
effectiveRules = current().rules.map(rule => ({ match: rule.match ?? {}, style: rule.style, priority: rule.priority ?? 0 }))
|
|
488
|
+
},
|
|
489
|
+
onChange: () => {},
|
|
490
|
+
validate: value => {
|
|
491
|
+
for (const rule of value.rules) {
|
|
492
|
+
if (rule.style === '' || /[^a-z0-9-]/.test(rule.style)) {
|
|
493
|
+
throw new Error(`dsh-output-styles: rule style ${JSON.stringify(rule.style)} must be a kebab-case renderer id`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// The /export command: renders the current session's message surface to
|
|
501
|
+
// Markdown or sanitized HTML through the renderer pipeline. The document
|
|
502
|
+
// itself is the visible artifact; the original lines are the session log
|
|
503
|
+
// the export was projected from — rendered and original stay reconstructable.
|
|
504
|
+
if (resolved.enableExport) {
|
|
505
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
506
|
+
commandCtx.commands.register({
|
|
507
|
+
name: 'export',
|
|
508
|
+
description: 'Export this session as Markdown or HTML (renderer-aware)',
|
|
509
|
+
input: { hint: '[markdown|html] [--renderer=<id>]' },
|
|
510
|
+
handler: async ({ agent, rawInput }) => {
|
|
511
|
+
const input = parseExportInput(rawInput)
|
|
512
|
+
if (input.kind === 'error') {
|
|
513
|
+
return { kind: 'error', text: 'usage: /export [markdown|html] [--renderer=<id>]' }
|
|
514
|
+
}
|
|
515
|
+
const lines = conversationLines(agent.session.events)
|
|
516
|
+
const rules: StyleRule[] = input.renderer === undefined
|
|
517
|
+
? [...effectiveRules]
|
|
518
|
+
: [{ match: {}, style: input.renderer, priority: 0 }]
|
|
519
|
+
const document = renderExport(renderers, lines, input.format, rules)
|
|
520
|
+
return { kind: 'success', text: document.text }
|
|
521
|
+
},
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Parsed `/export` invocation. */
|
|
528
|
+
type ExportInput = { kind: 'ok'; format: 'markdown' | 'html'; renderer?: string } | { kind: 'error' }
|
|
529
|
+
|
|
530
|
+
/** Parse `/export [markdown|html] [--renderer=<id>]` from the raw command input. */
|
|
531
|
+
export function parseExportInput(rawInput: unknown): ExportInput {
|
|
532
|
+
const raw = String(rawInput ?? '').trim()
|
|
533
|
+
const parts = raw === '' ? [] : raw.split(/\s+/)
|
|
534
|
+
let format: 'markdown' | 'html' = 'markdown'
|
|
535
|
+
let renderer: string | undefined
|
|
536
|
+
for (const part of parts) {
|
|
537
|
+
if (part === 'markdown' || part === 'html') {
|
|
538
|
+
format = part
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
const match = /^--renderer=([a-z0-9][a-z0-9-]*)$/.exec(part)
|
|
542
|
+
if (match !== null) {
|
|
543
|
+
renderer = match[1]
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
return { kind: 'error' }
|
|
547
|
+
}
|
|
548
|
+
return { kind: 'ok', format, ...renderer === undefined ? {} : { renderer } }
|
|
439
549
|
}
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
12
12
|
import { z as zod } from 'zod'
|
|
13
13
|
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
14
|
+
import type { OutputRenderer, RenderContext, RenderedText } from './renderers.ts'
|
|
14
15
|
|
|
15
16
|
/** The reserved switch target that removes a session's selection. */
|
|
16
17
|
export const OFF = 'off'
|
|
@@ -84,3 +85,30 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
|
84
85
|
style: StyleSelectionView
|
|
85
86
|
}
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
/** The `ctx.outputRenderers` service: the output.render.* renderer registry. */
|
|
90
|
+
export interface OutputRenderersService {
|
|
91
|
+
/** Register a renderer (returns the disposer; caller owns it via ctx.effect). */
|
|
92
|
+
register(renderer: OutputRenderer): () => void
|
|
93
|
+
/** Every registered renderer, deterministic order (priority desc, registration asc). */
|
|
94
|
+
list(): OutputRenderer[]
|
|
95
|
+
/** Renderers matching a request, highest priority first. */
|
|
96
|
+
resolve(context: RenderContext): OutputRenderer[]
|
|
97
|
+
/** Render text through the `output.render/before` waterfall, then rules + matched renderers. */
|
|
98
|
+
renderText(text: string, context: RenderContext): Promise<RenderedText>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
declare module '@deepseek-ai/cordis' {
|
|
102
|
+
interface Context {
|
|
103
|
+
/** The output.render.* renderer registry provided by dsh-output-styles. */
|
|
104
|
+
outputRenderers: OutputRenderersService
|
|
105
|
+
}
|
|
106
|
+
interface Events {
|
|
107
|
+
/**
|
|
108
|
+
* Pre-render waterfall: listeners receive `{ text, context }` and MUST
|
|
109
|
+
* call `next()` with their transformed request (or the unchanged one);
|
|
110
|
+
* returning without `next()` short-circuits the render pipeline.
|
|
111
|
+
*/
|
|
112
|
+
'output.render/before'(request: { text: string; context: RenderContext }, next: (request: { text: string; context: RenderContext }) => Promise<RenderedText>): Promise<RenderedText>
|
|
113
|
+
}
|
|
114
|
+
}
|