dsh-prompt-customizer 0.2.7 → 0.3.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.
@@ -1,268 +1,268 @@
1
- /** Pure preset / section helpers shared by the client UI and the node tests. */
2
- import type { Config, Inventory, Preset, PresetData } from './types.ts'
3
-
4
- export interface Section {
5
- name: string
6
- order: number
7
- text?: string
8
- active: boolean
9
- replaced: boolean
10
- /** `system` = read from the host prompt inventory (not deletable); `custom` = added by this plugin (deletable). */
11
- source?: 'system' | 'custom'
12
- }
13
-
14
- /** The config fields a preset apply must write back to the settings scope. */
15
- export interface ConfigPatch {
16
- sections: string[]
17
- replace: Record<string, string>
18
- inject: Array<{ name: string; order: number; text: string }>
19
- tools: { exclude: string[]; include: string[] }
20
- }
21
-
22
- /**
23
- * Merge the inventory sections with the user's inject list, sorted by order.
24
- * Sections read from the inventory (other plugins) are `system`; sections this
25
- * plugin generated carry a hidden `custom` marker and are `custom`. The source
26
- * is decided by the hidden marker — never by name collisions — so a custom
27
- * section keeps its identity (and its deletability) even when its name happens
28
- * to match an inventory section, and across preset switches. Custom sections
29
- * always render their own text; they can never be "<动态生成>".
30
- */
31
- export function mergeSections(inv: Inventory | null, cfg: Config, blockedNames: ReadonlySet<string>): Section[] {
32
- const map = new Map<string, Section>()
33
- for (const sec of inv?.sections ?? []) map.set(sec.name, { ...sec, source: 'system' })
34
- for (const item of cfg.inject ?? []) {
35
- const isCustom = item.custom === true
36
- const existing = map.get(item.name)
37
- if (existing) {
38
- map.set(item.name, isCustom
39
- ? { ...existing, order: item.order, text: item.text ?? '', source: 'custom' }
40
- : { ...existing, order: item.order, source: 'system' })
41
- } else {
42
- map.set(item.name, {
43
- name: item.name,
44
- order: item.order,
45
- text: item.text ?? '',
46
- active: !blockedNames.has(item.name),
47
- replaced: false,
48
- source: isCustom ? 'custom' : 'system',
49
- })
50
- }
51
- }
52
- return [...map.values()].sort((a, b) => a.order - b.order)
53
- }
54
-
55
- /**
56
- * Resolve a preset's relative order into an absolute ordered list (0..n-1).
57
- * Sections with no anchor (or whose anchor is absent) come first in preset
58
- * order; each anchored section is inserted right after its anchor; any
59
- * remaining sections (cycles) are appended.
60
- */
61
- export function resolveOrder(presetOrder: PresetData['order']): Array<{ name: string; order: number; text: string; custom?: boolean }> {
62
- const list = presetOrder ?? []
63
- const afterMap = new Map<string, string | undefined>()
64
- const textMap = new Map<string, string>()
65
- const customMap = new Map<string, boolean>()
66
- for (const sec of list) {
67
- afterMap.set(sec.name, sec.after)
68
- textMap.set(sec.name, sec.text)
69
- customMap.set(sec.name, sec.custom === true)
70
- }
71
-
72
- const result: string[] = []
73
- const placed = new Set<string>()
74
-
75
- for (const sec of list) {
76
- const anchor = afterMap.get(sec.name)
77
- if (!anchor || !afterMap.has(anchor)) { result.push(sec.name); placed.add(sec.name) }
78
- }
79
-
80
- let changed = true
81
- while (changed) {
82
- changed = false
83
- for (const sec of list) {
84
- if (placed.has(sec.name)) continue
85
- const anchor = afterMap.get(sec.name)
86
- if (anchor && placed.has(anchor)) {
87
- result.splice(result.indexOf(anchor) + 1, 0, sec.name)
88
- placed.add(sec.name)
89
- changed = true
90
- }
91
- }
92
- }
93
-
94
- for (const sec of list) {
95
- if (!placed.has(sec.name)) { result.push(sec.name); placed.add(sec.name) }
96
- }
97
-
98
- return result.map((name, i) => ({ name, order: i, text: textMap.get(name) ?? '', custom: customMap.get(name) }))
99
- }
100
-
101
- /**
102
- * Build a preset snapshot from the current config. The order list is derived
103
- * from the MERGED section list (not just `cfg.inject`) so that a preset saved
104
- * after only blocking sections (without reordering) still carries the full
105
- * ordered set, and thus a meaningful "active list" when applied.
106
- */
107
- export function buildPresetData(cfg: Config, merged: Section[]): PresetData {
108
- // Only custom (plugin-added) sections carry their own text into the preset's
109
- // order list. System sections keep an empty text so applying the preset only
110
- // reorders them and never freezes their dynamically generated content.
111
- const order = merged.map((sec, i) => ({
112
- name: sec.name,
113
- after: i > 0 ? merged[i - 1].name : undefined,
114
- text: sec.source === 'custom' ? (sec.text ?? '') : '',
115
- custom: sec.source === 'custom',
116
- }))
117
- return {
118
- sections: cfg.sections,
119
- replace: cfg.replace,
120
- order,
121
- tools: cfg.tools,
122
- }
123
- }
124
-
125
- /**
126
- * Compute the config patch when applying a preset:
127
- * - same-name sections are overridden (the order list drives this at runtime)
128
- * - preset sections missing from the current prompt are added
129
- * - current sections NOT in the preset's ordered list are disabled by default
130
- * - only the preset's ACTIVE sections (in the order list but not in its block
131
- * list) are unblocked; the preset's own blocked sections stay blocked.
132
- *
133
- * Custom injected sections that exist in the current config but are NOT in the
134
- * preset's ordered list are preserved in the resulting inject list (so they
135
- * stay visible) and disabled (added to the blocked set), rather than silently
136
- * dropped — a preset must not make the user's own injected sections vanish.
137
- */
138
- export function applyPresetData(data: PresetData, cfg: Config, currentNames: ReadonlySet<string>): ConfigPatch {
139
- const presetOrder = data.order ?? []
140
- const presetNames = new Set(presetOrder.map((x) => x.name))
141
- const blocked = new Set(data.sections ?? [])
142
-
143
- // Inject list = the preset's resolved order, then any custom injected
144
- // sections present in the current config but absent from the preset list.
145
- // Keeping them in `inject` makes them visible; they are disabled below.
146
- // Kept customs continue after the preset's own indexes; if the preset covers
147
- // only a small subset they may tie with the inventory's native orders — the
148
- // merge view sorts stably (inventory first), so ordering stays deterministic,
149
- // and the next reorder/persist re-indexes everything contiguously anyway.
150
- const inject = resolveOrder(presetOrder)
151
- const kept = new Set(inject.map((x) => x.name))
152
- let order = inject.length
153
- for (const item of cfg.inject ?? []) {
154
- if (!item || !item.name || presetNames.has(item.name) || kept.has(item.name)) continue
155
- kept.add(item.name)
156
- inject.push({ name: item.name, order: order++, text: item.text ?? '', custom: item.custom === true })
157
- }
158
-
159
- // Only enforce the "active set" when the preset actually defines an order.
160
- // An empty order (e.g. a hand-authored / imported preset that only blocks a
161
- // few sections) must not default-disable every current section.
162
- if (presetOrder.length > 0) {
163
- const activeNames = new Set([...presetNames].filter((n) => !blocked.has(n)))
164
- for (const name of currentNames) if (!presetNames.has(name)) blocked.add(name)
165
- for (const name of activeNames) blocked.delete(name)
166
- }
167
-
168
- return {
169
- sections: [...blocked],
170
- replace: { ...(cfg.replace ?? {}), ...(data.replace ?? {}) },
171
- inject,
172
- tools: { exclude: data.tools?.exclude ?? [], include: data.tools?.include ?? [] },
173
- }
174
- }
175
-
176
- /**
177
- * Remove a custom (plugin-added) section from the config: it disappears from
178
- * the inject list, is no longer forced into the blocked set, and any replace
179
- * text for it is cleared. The remaining inject entries are re-indexed to a
180
- * contiguous 0..n-1 order (the virtual index model), so removal never leaves
181
- * holes. System sections read from the host inventory cannot be removed this
182
- * way (they would just come back on the next inventory read).
183
- */
184
- export function removeSection(name: string, cfg: Config): Pick<Config, 'sections' | 'inject' | 'replace'> {
185
- const sections = (cfg.sections ?? []).filter((n) => n !== name)
186
- const inject = (cfg.inject ?? [])
187
- .filter((item) => item.name !== name)
188
- .map((item, i) => ({ ...item, order: i }))
189
- const replace = { ...(cfg.replace ?? {}) }
190
- delete replace[name]
191
- return { sections, inject, replace }
192
- }
193
-
194
- // ── Tool filtering ──────────────────────────────────────────────────────────
195
-
196
- export type ToolsCfg = { exclude?: string[]; include?: string[] }
197
-
198
- /** Whether a tool is hidden under the current include/exclude config. */
199
- export function isToolHidden(name: string, toolsCfg: ToolsCfg | undefined): boolean {
200
- const include = toolsCfg?.include ?? []
201
- const exclude = toolsCfg?.exclude ?? []
202
- return include.length > 0 ? !include.includes(name) : exclude.includes(name)
203
- }
204
-
205
- /** Toggle a tool's hidden state, returning a new tools config. */
206
- export function toggleTool(name: string, currentlyHidden: boolean, toolsCfg: ToolsCfg | undefined): ToolsCfg {
207
- const include = toolsCfg?.include ?? []
208
- const exclude = toolsCfg?.exclude ?? []
209
- if (include.length > 0) {
210
- const next = include.slice()
211
- if (currentlyHidden) {
212
- next.push(name)
213
- // Re-showing a tool in whitelist mode must also clear any stale blacklist
214
- // entry, otherwise the name lingers in both lists and reappears as hidden
215
- // if the user later switches back to blacklist mode.
216
- const nextExclude = exclude.filter((n) => n !== name)
217
- return { exclude: nextExclude, include: next }
218
- }
219
- const i = next.indexOf(name)
220
- if (i >= 0) next.splice(i, 1)
221
- return { exclude, include: next }
222
- }
223
- const next = exclude.slice()
224
- if (currentlyHidden) { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
225
- else next.push(name)
226
- return { exclude: next, include }
227
- }
228
-
229
- /**
230
- * Switch between include (whitelist) and exclude (blacklist) modes. Turning
231
- * include mode on discards names unknown to the current tool set and seeds the
232
- * include list from the currently non-excluded tools; turning it off clears the
233
- * include list (keep only known exclude names).
234
- */
235
- export function setToolMode(on: boolean, toolsCfg: ToolsCfg | undefined, toolNames: string[]): ToolsCfg {
236
- const known = new Set(toolNames)
237
- const exclude = (toolsCfg?.exclude ?? []).filter((n) => known.has(n))
238
- if (on) return { exclude, include: toolNames.filter((n) => !exclude.includes(n)) }
239
- return { exclude, include: [] }
240
- }
241
-
242
- // ── Preset list operations ──────────────────────────────────────────────────
243
-
244
- /** Serialize a preset for export (JSON-safe full snapshot). */
245
- export function serializePreset(preset: Preset): { name: string; data: PresetData } {
246
- return { name: preset.name, data: preset.data }
247
- }
248
-
249
- /**
250
- * Import preset(s): same-name presets are skipped, only distinct names are
251
- * added. Returns a new presets list (or the original if nothing changed).
252
- */
253
- export function addImportedPresets(existing: Preset[], parsed: unknown, makeId: () => string): Preset[] {
254
- const incoming = Array.isArray(parsed) ? parsed : [parsed]
255
- const existingNames = new Set(existing.map((p) => p.name))
256
- const added = incoming
257
- .filter((p) => p && typeof (p as { name?: unknown }).name === 'string' && !existingNames.has((p as Preset).name))
258
- .map((p) => ({ id: makeId(), name: (p as Preset).name, data: ((p as Preset).data ?? {}) as PresetData }))
259
- return added.length > 0 ? [...existing, ...added] : existing
260
- }
261
-
262
- /** Delete a preset, clearing the active id if it pointed at the removed one. */
263
- export function removePreset(presets: Preset[], id: string, activeId?: string): { presets: Preset[]; activeId?: string } {
264
- return {
265
- presets: presets.filter((p) => p.id !== id),
266
- activeId: activeId === id ? undefined : activeId,
267
- }
268
- }
1
+ /** Pure preset / section helpers shared by the client UI and the node tests. */
2
+ import type { Config, Inventory, Preset, PresetData } from './types.ts'
3
+
4
+ export interface Section {
5
+ name: string
6
+ order: number
7
+ text?: string
8
+ active: boolean
9
+ replaced: boolean
10
+ /** `system` = read from the host prompt inventory (not deletable); `custom` = added by this plugin (deletable). */
11
+ source?: 'system' | 'custom'
12
+ }
13
+
14
+ /** The config fields a preset apply must write back to the settings scope. */
15
+ export interface ConfigPatch {
16
+ sections: string[]
17
+ replace: Record<string, string>
18
+ inject: Array<{ name: string; order: number; text: string }>
19
+ tools: { exclude: string[]; include: string[] }
20
+ }
21
+
22
+ /**
23
+ * Merge the inventory sections with the user's inject list, sorted by order.
24
+ * Sections read from the inventory (other plugins) are `system`; sections this
25
+ * plugin generated carry a hidden `custom` marker and are `custom`. The source
26
+ * is decided by the hidden marker — never by name collisions — so a custom
27
+ * section keeps its identity (and its deletability) even when its name happens
28
+ * to match an inventory section, and across preset switches. Custom sections
29
+ * always render their own text; they can never be "<动态生成>".
30
+ */
31
+ export function mergeSections(inv: Inventory | null, cfg: Config, blockedNames: ReadonlySet<string>): Section[] {
32
+ const map = new Map<string, Section>()
33
+ for (const sec of inv?.sections ?? []) map.set(sec.name, { ...sec, source: 'system' })
34
+ for (const item of cfg.inject ?? []) {
35
+ const isCustom = item.custom === true
36
+ const existing = map.get(item.name)
37
+ if (existing) {
38
+ map.set(item.name, isCustom
39
+ ? { ...existing, order: item.order, text: item.text ?? '', source: 'custom' }
40
+ : { ...existing, order: item.order, source: 'system' })
41
+ } else {
42
+ map.set(item.name, {
43
+ name: item.name,
44
+ order: item.order,
45
+ text: item.text ?? '',
46
+ active: !blockedNames.has(item.name),
47
+ replaced: false,
48
+ source: isCustom ? 'custom' : 'system',
49
+ })
50
+ }
51
+ }
52
+ return [...map.values()].sort((a, b) => a.order - b.order).map((sec, i) => ({ ...sec, order: i }))
53
+ }
54
+
55
+ /**
56
+ * Resolve a preset's relative order into an absolute ordered list (0..n-1).
57
+ * Sections with no anchor (or whose anchor is absent) come first in preset
58
+ * order; each anchored section is inserted right after its anchor; any
59
+ * remaining sections (cycles) are appended.
60
+ */
61
+ export function resolveOrder(presetOrder: PresetData['order']): Array<{ name: string; order: number; text: string; custom?: boolean }> {
62
+ const list = presetOrder ?? []
63
+ const afterMap = new Map<string, string | undefined>()
64
+ const textMap = new Map<string, string>()
65
+ const customMap = new Map<string, boolean>()
66
+ for (const sec of list) {
67
+ afterMap.set(sec.name, sec.after)
68
+ textMap.set(sec.name, sec.text)
69
+ customMap.set(sec.name, sec.custom === true)
70
+ }
71
+
72
+ const result: string[] = []
73
+ const placed = new Set<string>()
74
+
75
+ for (const sec of list) {
76
+ const anchor = afterMap.get(sec.name)
77
+ if (!anchor || !afterMap.has(anchor)) { result.push(sec.name); placed.add(sec.name) }
78
+ }
79
+
80
+ let changed = true
81
+ while (changed) {
82
+ changed = false
83
+ for (const sec of list) {
84
+ if (placed.has(sec.name)) continue
85
+ const anchor = afterMap.get(sec.name)
86
+ if (anchor && placed.has(anchor)) {
87
+ result.splice(result.indexOf(anchor) + 1, 0, sec.name)
88
+ placed.add(sec.name)
89
+ changed = true
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const sec of list) {
95
+ if (!placed.has(sec.name)) { result.push(sec.name); placed.add(sec.name) }
96
+ }
97
+
98
+ return result.map((name, i) => ({ name, order: i, text: textMap.get(name) ?? '', custom: customMap.get(name) }))
99
+ }
100
+
101
+ /**
102
+ * Build a preset snapshot from the current config. The order list is derived
103
+ * from the MERGED section list (not just `cfg.inject`) so that a preset saved
104
+ * after only blocking sections (without reordering) still carries the full
105
+ * ordered set, and thus a meaningful "active list" when applied.
106
+ */
107
+ export function buildPresetData(cfg: Config, merged: Section[]): PresetData {
108
+ // Only custom (plugin-added) sections carry their own text into the preset's
109
+ // order list. System sections keep an empty text so applying the preset only
110
+ // reorders them and never freezes their dynamically generated content.
111
+ const order = merged.map((sec, i) => ({
112
+ name: sec.name,
113
+ after: i > 0 ? merged[i - 1].name : undefined,
114
+ text: sec.source === 'custom' ? (sec.text ?? '') : '',
115
+ custom: sec.source === 'custom',
116
+ }))
117
+ return {
118
+ sections: cfg.sections,
119
+ replace: cfg.replace,
120
+ order,
121
+ tools: cfg.tools,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Compute the config patch when applying a preset:
127
+ * - same-name sections are overridden (the order list drives this at runtime)
128
+ * - preset sections missing from the current prompt are added
129
+ * - current sections NOT in the preset's ordered list are disabled by default
130
+ * - only the preset's ACTIVE sections (in the order list but not in its block
131
+ * list) are unblocked; the preset's own blocked sections stay blocked.
132
+ *
133
+ * Custom injected sections that exist in the current config but are NOT in the
134
+ * preset's ordered list are preserved in the resulting inject list (so they
135
+ * stay visible) and disabled (added to the blocked set), rather than silently
136
+ * dropped — a preset must not make the user's own injected sections vanish.
137
+ */
138
+ export function applyPresetData(data: PresetData, cfg: Config, currentNames: ReadonlySet<string>): ConfigPatch {
139
+ const presetOrder = data.order ?? []
140
+ const presetNames = new Set(presetOrder.map((x) => x.name))
141
+ const blocked = new Set(data.sections ?? [])
142
+
143
+ // Inject list = the preset's resolved order, then any custom injected
144
+ // sections present in the current config but absent from the preset list.
145
+ // Keeping them in `inject` makes them visible; they are disabled below.
146
+ // Kept customs continue after the preset's own indexes; if the preset covers
147
+ // only a small subset they may tie with the inventory's native orders — the
148
+ // merge view sorts stably (inventory first), so ordering stays deterministic,
149
+ // and the next reorder/persist re-indexes everything contiguously anyway.
150
+ const inject = resolveOrder(presetOrder)
151
+ const kept = new Set(inject.map((x) => x.name))
152
+ let order = inject.length
153
+ for (const item of cfg.inject ?? []) {
154
+ if (!item || !item.name || presetNames.has(item.name) || kept.has(item.name)) continue
155
+ kept.add(item.name)
156
+ inject.push({ name: item.name, order: order++, text: item.text ?? '', custom: item.custom === true })
157
+ }
158
+
159
+ // Only enforce the "active set" when the preset actually defines an order.
160
+ // An empty order (e.g. a hand-authored / imported preset that only blocks a
161
+ // few sections) must not default-disable every current section.
162
+ if (presetOrder.length > 0) {
163
+ const activeNames = new Set([...presetNames].filter((n) => !blocked.has(n)))
164
+ for (const name of currentNames) if (!presetNames.has(name)) blocked.add(name)
165
+ for (const name of activeNames) blocked.delete(name)
166
+ }
167
+
168
+ return {
169
+ sections: [...blocked],
170
+ replace: { ...(cfg.replace ?? {}), ...(data.replace ?? {}) },
171
+ inject,
172
+ tools: { exclude: data.tools?.exclude ?? [], include: data.tools?.include ?? [] },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Remove a custom (plugin-added) section from the config: it disappears from
178
+ * the inject list, is no longer forced into the blocked set, and any replace
179
+ * text for it is cleared. The remaining inject entries are re-indexed to a
180
+ * contiguous 0..n-1 order (the virtual index model), so removal never leaves
181
+ * holes. System sections read from the host inventory cannot be removed this
182
+ * way (they would just come back on the next inventory read).
183
+ */
184
+ export function removeSection(name: string, cfg: Config): Pick<Config, 'sections' | 'inject' | 'replace'> {
185
+ const sections = (cfg.sections ?? []).filter((n) => n !== name)
186
+ const inject = (cfg.inject ?? [])
187
+ .filter((item) => item.name !== name)
188
+ .map((item, i) => ({ ...item, order: i }))
189
+ const replace = { ...(cfg.replace ?? {}) }
190
+ delete replace[name]
191
+ return { sections, inject, replace }
192
+ }
193
+
194
+ // ── Tool filtering ──────────────────────────────────────────────────────────
195
+
196
+ export type ToolsCfg = { exclude?: string[]; include?: string[] }
197
+
198
+ /** Whether a tool is hidden under the current include/exclude config. */
199
+ export function isToolHidden(name: string, toolsCfg: ToolsCfg | undefined): boolean {
200
+ const include = toolsCfg?.include ?? []
201
+ const exclude = toolsCfg?.exclude ?? []
202
+ return include.length > 0 ? !include.includes(name) : exclude.includes(name)
203
+ }
204
+
205
+ /** Toggle a tool's hidden state, returning a new tools config. */
206
+ export function toggleTool(name: string, currentlyHidden: boolean, toolsCfg: ToolsCfg | undefined): ToolsCfg {
207
+ const include = toolsCfg?.include ?? []
208
+ const exclude = toolsCfg?.exclude ?? []
209
+ if (include.length > 0) {
210
+ const next = include.slice()
211
+ if (currentlyHidden) {
212
+ next.push(name)
213
+ // Re-showing a tool in whitelist mode must also clear any stale blacklist
214
+ // entry, otherwise the name lingers in both lists and reappears as hidden
215
+ // if the user later switches back to blacklist mode.
216
+ const nextExclude = exclude.filter((n) => n !== name)
217
+ return { exclude: nextExclude, include: next }
218
+ }
219
+ const i = next.indexOf(name)
220
+ if (i >= 0) next.splice(i, 1)
221
+ return { exclude, include: next }
222
+ }
223
+ const next = exclude.slice()
224
+ if (currentlyHidden) { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
225
+ else next.push(name)
226
+ return { exclude: next, include }
227
+ }
228
+
229
+ /**
230
+ * Switch between include (whitelist) and exclude (blacklist) modes. Turning
231
+ * include mode on discards names unknown to the current tool set and seeds the
232
+ * include list from the currently non-excluded tools; turning it off clears the
233
+ * include list (keep only known exclude names).
234
+ */
235
+ export function setToolMode(on: boolean, toolsCfg: ToolsCfg | undefined, toolNames: string[]): ToolsCfg {
236
+ const known = new Set(toolNames)
237
+ const exclude = (toolsCfg?.exclude ?? []).filter((n) => known.has(n))
238
+ if (on) return { exclude, include: toolNames.filter((n) => !exclude.includes(n)) }
239
+ return { exclude, include: [] }
240
+ }
241
+
242
+ // ── Preset list operations ──────────────────────────────────────────────────
243
+
244
+ /** Serialize a preset for export (JSON-safe full snapshot). */
245
+ export function serializePreset(preset: Preset): { name: string; data: PresetData } {
246
+ return { name: preset.name, data: preset.data }
247
+ }
248
+
249
+ /**
250
+ * Import preset(s): same-name presets are skipped, only distinct names are
251
+ * added. Returns a new presets list (or the original if nothing changed).
252
+ */
253
+ export function addImportedPresets(existing: Preset[], parsed: unknown, makeId: () => string): Preset[] {
254
+ const incoming = Array.isArray(parsed) ? parsed : [parsed]
255
+ const existingNames = new Set(existing.map((p) => p.name))
256
+ const added = incoming
257
+ .filter((p) => p && typeof (p as { name?: unknown }).name === 'string' && !existingNames.has((p as Preset).name))
258
+ .map((p) => ({ id: makeId(), name: (p as Preset).name, data: ((p as Preset).data ?? {}) as PresetData }))
259
+ return added.length > 0 ? [...existing, ...added] : existing
260
+ }
261
+
262
+ /** Delete a preset, clearing the active id if it pointed at the removed one. */
263
+ export function removePreset(presets: Preset[], id: string, activeId?: string): { presets: Preset[]; activeId?: string } {
264
+ return {
265
+ presets: presets.filter((p) => p.id !== id),
266
+ activeId: activeId === id ? undefined : activeId,
267
+ }
268
+ }
@@ -1,41 +1,41 @@
1
- /** Inline styles shared by the prompt-customizer panel components. */
2
- export const s = (() => {
3
- const flex = { display: 'flex', alignItems: 'center' }
4
- return {
5
- root: { fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5 },
6
- bar: { ...flex, gap: 6, marginBottom: 8, flexWrap: 'wrap' },
7
- tab: { padding: '4px 10px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', opacity: 0.75 },
8
- tabActive: { padding: '4px 10px', border: '1px solid rgba(128,128,128,.7)', borderRadius: 6, background: 'rgba(128,128,128,.18)', cursor: 'pointer', color: 'inherit' },
9
- refresh: { marginLeft: 'auto', padding: '4px 10px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit' },
10
- list: { display: 'flex', flexDirection: 'column', gap: 6 },
11
- row: { ...flex, gap: 8, padding: '6px 8px', border: '1px solid rgba(128,128,128,.2)', borderRadius: 8, background: 'rgba(128,128,128,.06)' },
12
- switchWrap: { ...flex, gap: 6, minWidth: 90 },
13
- rowBody: { flex: 1, minWidth: 0 },
14
- rowTitle: { ...flex, gap: 6, alignItems: 'baseline', flexWrap: 'wrap' },
15
- code: { fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' },
16
- orderTag: { color: 'rgba(128,128,128,.8)', fontSize: 11 },
17
- preview: { color: 'rgba(128,128,128,.85)', fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
18
- previewText: { fontFamily: 'monospace', fontSize: 12, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 480, overflowY: 'auto', padding: 10, border: '1px solid rgba(128,128,128,.2)', borderRadius: 8, background: 'rgba(0,0,0,.18)', margin: 0 },
19
- toolWrap: { display: 'flex', flexWrap: 'wrap', gap: 6 },
20
- toolChip: { fontFamily: 'monospace', fontSize: 12, color: 'rgba(128,128,128,.95)', border: '1px solid rgba(128,128,128,.3)', borderRadius: 6, padding: '2px 6px', cursor: 'help' },
21
- badgeOk: { color: '#3fb950', fontSize: 11, border: '1px solid rgba(63,185,80,.5)', borderRadius: 999, padding: '0 6px' },
22
- badgeBlocked: { color: '#f85149', fontSize: 11, border: '1px solid rgba(248,81,73,.5)', borderRadius: 999, padding: '0 6px' },
23
- badgeReplaced: { color: '#d29922', fontSize: 11, border: '1px solid rgba(210,153,34,.5)', borderRadius: 999, padding: '0 6px' },
24
- badgeCustom: { color: '#58a6ff', fontSize: 11, border: '1px solid rgba(88,166,255,.5)', borderRadius: 999, padding: '0 6px' },
25
- badgeSystem: { color: 'rgba(128,128,128,.85)', fontSize: 11, border: '1px solid rgba(128,128,128,.4)', borderRadius: 999, padding: '0 6px' },
26
- mini: { padding: '3px 8px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', whiteSpace: 'nowrap' },
27
- arrowCol: { display: 'flex', flexDirection: 'column', gap: 2 },
28
- arrow: { padding: '0 6px', border: '1px solid rgba(128,128,128,.3)', borderRadius: 4, background: 'transparent', cursor: 'pointer', color: 'inherit', fontSize: 11, lineHeight: 1.4, opacity: 0.8 },
29
- rowDragging: { opacity: 0.4 },
30
- rowDropAbove: { boxShadow: '0 -2px 0 0 #58a6ff' },
31
- rowDropBelow: { boxShadow: '0 2px 0 0 #58a6ff' },
32
- dragHandle: { cursor: 'grab', color: 'rgba(128,128,128,.7)', fontSize: 13, padding: '0 2px', userSelect: 'none' },
33
- injectBox: { marginTop: 10, padding: '8px', border: '1px dashed rgba(128,128,128,.4)', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 6 },
34
- injectRow: { ...flex, gap: 6 },
35
- editBox: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 },
36
- editInput: { padding: '6px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', color: 'inherit', fontFamily: 'inherit', fontSize: 12, resize: 'vertical', width: '100%', boxSizing: 'border-box' },
37
- input: { padding: '4px 6px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', color: 'inherit' },
38
- muted: { color: 'rgba(128,128,128,.75)', fontSize: 12 },
39
- error: { color: '#f85149', fontSize: 12, marginBottom: 6 },
40
- }
41
- })()
1
+ /** Inline styles shared by the prompt-customizer panel components. */
2
+ export const s = (() => {
3
+ const flex = { display: 'flex', alignItems: 'center' }
4
+ return {
5
+ root: { fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5 },
6
+ bar: { ...flex, gap: 6, marginBottom: 8, flexWrap: 'wrap' },
7
+ tab: { padding: '4px 10px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', opacity: 0.75 },
8
+ tabActive: { padding: '4px 10px', border: '1px solid rgba(128,128,128,.7)', borderRadius: 6, background: 'rgba(128,128,128,.18)', cursor: 'pointer', color: 'inherit' },
9
+ refresh: { marginLeft: 'auto', padding: '4px 10px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit' },
10
+ list: { display: 'flex', flexDirection: 'column', gap: 6 },
11
+ row: { ...flex, gap: 8, padding: '6px 8px', border: '1px solid rgba(128,128,128,.2)', borderRadius: 8, background: 'rgba(128,128,128,.06)' },
12
+ switchWrap: { ...flex, gap: 6, minWidth: 90 },
13
+ rowBody: { flex: 1, minWidth: 0 },
14
+ rowTitle: { ...flex, gap: 6, alignItems: 'baseline', flexWrap: 'wrap' },
15
+ code: { fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' },
16
+ orderTag: { color: 'rgba(128,128,128,.8)', fontSize: 11 },
17
+ preview: { color: 'rgba(128,128,128,.85)', fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
18
+ previewText: { fontFamily: 'monospace', fontSize: 12, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 480, overflowY: 'auto', padding: 10, border: '1px solid rgba(128,128,128,.2)', borderRadius: 8, background: 'rgba(0,0,0,.18)', margin: 0 },
19
+ toolWrap: { display: 'flex', flexWrap: 'wrap', gap: 6 },
20
+ toolChip: { fontFamily: 'monospace', fontSize: 12, color: 'rgba(128,128,128,.95)', border: '1px solid rgba(128,128,128,.3)', borderRadius: 6, padding: '2px 6px', cursor: 'help' },
21
+ badgeOk: { color: '#3fb950', fontSize: 11, border: '1px solid rgba(63,185,80,.5)', borderRadius: 999, padding: '0 6px' },
22
+ badgeBlocked: { color: '#f85149', fontSize: 11, border: '1px solid rgba(248,81,73,.5)', borderRadius: 999, padding: '0 6px' },
23
+ badgeReplaced: { color: '#d29922', fontSize: 11, border: '1px solid rgba(210,153,34,.5)', borderRadius: 999, padding: '0 6px' },
24
+ badgeCustom: { color: '#58a6ff', fontSize: 11, border: '1px solid rgba(88,166,255,.5)', borderRadius: 999, padding: '0 6px' },
25
+ badgeSystem: { color: 'rgba(128,128,128,.85)', fontSize: 11, border: '1px solid rgba(128,128,128,.4)', borderRadius: 999, padding: '0 6px' },
26
+ mini: { padding: '3px 8px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', whiteSpace: 'nowrap' },
27
+ arrowCol: { display: 'flex', flexDirection: 'column', gap: 2 },
28
+ arrow: { padding: '0 6px', border: '1px solid rgba(128,128,128,.3)', borderRadius: 4, background: 'transparent', cursor: 'pointer', color: 'inherit', fontSize: 11, lineHeight: 1.4, opacity: 0.8 },
29
+ rowDragging: { opacity: 0.4 },
30
+ rowDropAbove: { boxShadow: '0 -2px 0 0 #58a6ff' },
31
+ rowDropBelow: { boxShadow: '0 2px 0 0 #58a6ff' },
32
+ dragHandle: { cursor: 'grab', color: 'rgba(128,128,128,.7)', fontSize: 13, padding: '0 2px', userSelect: 'none' },
33
+ injectBox: { marginTop: 10, padding: '8px', border: '1px dashed rgba(128,128,128,.4)', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 6 },
34
+ injectRow: { ...flex, gap: 6 },
35
+ editBox: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 },
36
+ editInput: { padding: '6px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', color: 'inherit', fontFamily: 'inherit', fontSize: 12, resize: 'vertical', width: '100%', boxSizing: 'border-box' },
37
+ input: { padding: '4px 6px', border: '1px solid rgba(128,128,128,.35)', borderRadius: 6, background: 'transparent', color: 'inherit' },
38
+ muted: { color: 'rgba(128,128,128,.75)', fontSize: 12 },
39
+ error: { color: '#f85149', fontSize: 12, marginBottom: 6 },
40
+ }
41
+ })()