dsh-prompt-customizer 0.2.6 → 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.
- package/LICENSE +21 -21
- package/README.en.md +13 -32
- package/README.i18n.yaml +2 -2
- package/README.md +45 -38
- package/client/client.js +41 -16
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +8 -8
- package/lib/index.js +270 -254
- package/lib/schema.js +51 -51
- package/package.json +61 -61
- package/src/client/PresetsTab.tsx +110 -110
- package/src/client/PreviewTab.tsx +61 -59
- package/src/client/PreviewTools.tsx +31 -31
- package/src/client/SectionsTab.tsx +202 -201
- package/src/client/ToolsTab.tsx +48 -48
- package/src/client/index.ts +116 -115
- package/src/client/locales.ts +101 -97
- package/src/client/presets.ts +268 -268
- package/src/client/styles.ts +41 -41
- package/src/client/types.ts +50 -50
- package/lib/client.js +0 -344
package/src/client/presets.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -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
|
+
})()
|