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.
@@ -1,201 +1,202 @@
1
- /** Prompt-sections tab: block / replace / inject / reorder prompt sections. */
2
- import { createElement as h, useState, type ReactElement, type DragEvent, type ChangeEvent } from 'react'
3
- import type { Config, Inventory, SettingsScope } from './types.ts'
4
- import type { Translate } from './locales.ts'
5
- import { mergeSections, removeSection, type Section } from './presets.ts'
6
- import { s } from './styles.ts'
7
-
8
- export function SectionsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
9
- const replace = cfg.replace ?? {}
10
- const blockedNames = new Set(cfg.sections ?? [])
11
- const [editing, setEditing] = useState<string | null>(null)
12
- const [draft, setDraft] = useState('')
13
- const [dragName, setDragName] = useState<string | null>(null)
14
- const [dropTarget, setDropTarget] = useState<{ name: string; pos: 'above' | 'below' } | null>(null)
15
-
16
- // Merge inventory sections with user-injected sections, sorted by order.
17
- // Injected sections that don't exist in the original prompt appear here too,
18
- // positioned by their order value.
19
- const merged: Section[] = mergeSections(inv, cfg, blockedNames)
20
-
21
- const toggleBlock = (name: string, currentlyBlocked: boolean): void => {
22
- const list = (cfg.sections ?? []).slice()
23
- if (currentlyBlocked) { const i = list.indexOf(name); if (i >= 0) list.splice(i, 1) }
24
- else list.push(name)
25
- scope.set('sections', list)
26
- }
27
- const startReplace = (name: string, original: string): void => {
28
- // Echo the ORIGINAL prompt text so the user edits from the current value.
29
- setEditing(name)
30
- setDraft(replace[name] ?? original)
31
- }
32
- const commitReplace = (name: string): void => {
33
- const next = { ...replace }
34
- if (draft.trim() === '') delete next[name]
35
- else next[name] = draft
36
- scope.set('replace', next)
37
- setEditing(null)
38
- }
39
- const clearReplace = (name: string): void => {
40
- const next = { ...replace }
41
- delete next[name]
42
- scope.set('replace', next)
43
- }
44
-
45
- // Persist the full ordered list. We treat the sorted list as an array and
46
- // re-index every section with a sequential integer order (0, 1, 2, …), so
47
- // there are never duplicate or fractional orders. System sections keep empty
48
- // text (so we never freeze their dynamically generated content); custom
49
- // (plugin-generated) sections keep their own text and the hidden `custom`
50
- // marker, so they render real content and stay identifiable/deletable.
51
- const persistOrder = (ordered: Section[]): void => {
52
- const list = ordered.map((sec, i) => {
53
- const existing = (cfg.inject ?? []).find((x) => x.name === sec.name)
54
- const isCustom = sec.source === 'custom'
55
- return { name: sec.name, order: i, text: isCustom ? (sec.text ?? '') : (existing?.text ?? ''), custom: isCustom }
56
- })
57
- scope.set('inject', list)
58
- }
59
-
60
- // Delete a custom (plugin-added) section: remove it from inject / sections /
61
- // replace so it no longer appears in the panel at all.
62
- const removeCustom = (name: string): void => {
63
- const patch = removeSection(name, cfg)
64
- scope.set('sections', patch.sections)
65
- scope.set('inject', patch.inject)
66
- scope.set('replace', patch.replace)
67
- }
68
-
69
- // Move a section up/down one slot (array-index swap), then re-index.
70
- const moveOrder = (index: number, dir: -1 | 1): void => {
71
- const target = index + dir
72
- if (target < 0 || target >= merged.length) return
73
- const next = merged.slice()
74
- const [item] = next.splice(index, 1)
75
- next.splice(target, 0, item)
76
- persistOrder(next)
77
- }
78
-
79
- const onDragStart = (e: DragEvent, name: string): void => {
80
- setDragName(name)
81
- e.dataTransfer.effectAllowed = 'move'
82
- try { e.dataTransfer.setData('text/plain', name) } catch { /* ignore */ }
83
- }
84
- const onDragOver = (e: DragEvent, name: string): void => {
85
- if (!dragName || dragName === name) return
86
- e.preventDefault()
87
- e.dataTransfer.dropEffect = 'move'
88
- const rect = e.currentTarget.getBoundingClientRect()
89
- const pos = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
90
- setDropTarget({ name, pos })
91
- }
92
- const onDrop = (e: DragEvent, target: Section): void => {
93
- e.preventDefault()
94
- if (!dragName || dragName === target.name) { setDragName(null); setDropTarget(null); return }
95
- const dragged = merged.find((x) => x.name === dragName)
96
- if (!dragged) { setDragName(null); setDropTarget(null); return }
97
- const next = merged.filter((x) => x.name !== dragName)
98
- const idx = next.findIndex((x) => x.name === target.name)
99
- const pos = dropTarget?.name === target.name ? dropTarget.pos : 'below'
100
- next.splice(pos === 'above' ? idx : idx + 1, 0, dragged)
101
- persistOrder(next)
102
- setDragName(null)
103
- setDropTarget(null)
104
- }
105
- const onDragEnd = (): void => { setDragName(null); setDropTarget(null) }
106
-
107
- // Add a new injected section, placed by its order hint, then re-index. The
108
- // section is marked `custom` (the hidden marker) so it is identified as
109
- // plugin-generated and thus deletable, and its own text is preserved.
110
- const addSection = (name: string, order: number, text: string): void => {
111
- const next = merged.slice()
112
- const entry: Section = { name, order, text, active: true, replaced: false, source: 'custom' }
113
- const existing = next.findIndex((x) => x.name === name)
114
- if (existing >= 0) next[existing] = entry
115
- else {
116
- const idx = next.findIndex((x) => x.order > order)
117
- if (idx < 0) next.push(entry)
118
- else next.splice(idx, 0, entry)
119
- }
120
- persistOrder(next)
121
- }
122
-
123
- return h('div', { style: s.list }, [
124
- merged.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
125
- merged.map((sec, index) => {
126
- const blocked = blockedNames.has(sec.name)
127
- const isEditing = editing === sec.name
128
- const isDragging = dragName === sec.name
129
- const isDropTarget = dropTarget?.name === sec.name
130
- const rowStyle = {
131
- ...s.row,
132
- ...(isDragging ? s.rowDragging : {}),
133
- ...(isDropTarget && dropTarget.pos === 'above' ? s.rowDropAbove : {}),
134
- ...(isDropTarget && dropTarget.pos === 'below' ? s.rowDropBelow : {}),
135
- }
136
- return h('div', {
137
- key: sec.name,
138
- style: rowStyle,
139
- draggable: true,
140
- onDragStart: (e: DragEvent) => onDragStart(e, sec.name),
141
- onDragOver: (e: DragEvent) => onDragOver(e, sec.name),
142
- onDrop: (e: DragEvent) => onDrop(e, sec),
143
- onDragEnd,
144
- }, [
145
- h('span', { style: s.dragHandle, title: t('drag') }, '⠿'),
146
- h('label', { style: s.switchWrap }, [
147
- h('input', { type: 'checkbox', checked: blocked, onChange: () => toggleBlock(sec.name, blocked) }),
148
- h('span', { style: blocked ? s.badgeBlocked : s.badgeOk }, blocked ? t('blockedOn') : t('blockedOff')),
149
- ]),
150
- h('div', { style: s.rowBody }, [
151
- h('div', { style: s.rowTitle }, [
152
- h('span', { style: s.code }, sec.name),
153
- h('span', { style: s.orderTag }, '#' + index),
154
- h('span', { style: sec.source === 'custom' ? s.badgeCustom : s.badgeSystem }, sec.source === 'custom' ? t('manual') : t('system')),
155
- sec.replaced ? h('span', { style: s.badgeReplaced }, t('replaced')) : null,
156
- ]),
157
- isEditing
158
- ? h('div', { style: s.editBox }, [
159
- h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
160
- h('div', { style: s.injectRow }, [
161
- h('button', { style: s.mini, onClick: () => commitReplace(sec.name) }, t('add')),
162
- h('button', { style: s.mini, onClick: () => setEditing(null) }, t('clearReplace')),
163
- ]),
164
- ])
165
- : h('div', { style: s.preview }, String(sec.text ?? '').slice(0, 140) || (sec.source === 'custom' ? t('empty') : t('dynamic'))),
166
- ]),
167
- h('div', { style: s.arrowCol }, [
168
- h('button', { style: s.arrow, disabled: index === 0, onClick: () => moveOrder(index, -1), title: t('moveUp') }, '↑'),
169
- h('button', { style: s.arrow, disabled: index === merged.length - 1, onClick: () => moveOrder(index, 1), title: t('moveDown') }, '↓'),
170
- ]),
171
- h('button', { style: s.mini, onClick: () => replace[sec.name] ? clearReplace(sec.name) : startReplace(sec.name, String(sec.text ?? '')) }, replace[sec.name] ? t('clearReplace') : t('replace')),
172
- sec.source === 'custom' ? h('button', { style: s.mini, onClick: () => removeCustom(sec.name), title: t('delete') }, t('delete')) : null,
173
- ])
174
- }),
175
- h('div', { style: s.injectBox }, [
176
- h('div', { style: s.rowTitle }, t('injectNew')),
177
- h(InjectForm, { onAdd: addSection, t }),
178
- ]),
179
- ])
180
- }
181
-
182
- function InjectForm({ onAdd, t }: { onAdd: (name: string, order: number, text: string) => void; t: Translate }): ReactElement {
183
- const [name, setName] = useState('')
184
- const [order, setOrder] = useState('120')
185
- const [text, setText] = useState('')
186
-
187
- const submit = (): void => {
188
- if (!name.trim()) return
189
- onAdd(name.trim(), Number(order) || 120, text)
190
- setName(''); setText(''); setOrder('120')
191
- }
192
-
193
- return h('div', {}, [
194
- h('div', { style: s.injectRow }, [
195
- h('input', { style: { ...s.input, width: '22%' }, placeholder: t('name'), value: name, onChange: (e) => setName(e.target.value) }),
196
- h('input', { style: { ...s.input, width: '12%' }, type: 'number', placeholder: t('order'), value: order, onChange: (e) => setOrder(e.target.value) }),
197
- h('input', { style: { ...s.input, flex: 1 }, placeholder: t('text'), value: text, onChange: (e) => setText(e.target.value) }),
198
- h('button', { style: s.mini, onClick: submit }, t('add')),
199
- ]),
200
- ])
201
- }
1
+ /** Prompt-sections tab: block / replace / inject / reorder prompt sections. */
2
+ import { createElement as h, useState, type ReactElement, type DragEvent, type ChangeEvent } from 'react'
3
+ import type { Config, Inventory, SettingsScope } from './types.ts'
4
+ import type { Translate } from './locales.ts'
5
+ import { mergeSections, removeSection, type Section } from './presets.ts'
6
+ import { s } from './styles.ts'
7
+
8
+ export function SectionsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
9
+ const replace = cfg.replace ?? {}
10
+ const blockedNames = new Set(cfg.sections ?? [])
11
+ const [editing, setEditing] = useState<string | null>(null)
12
+ const [draft, setDraft] = useState('')
13
+ const [dragName, setDragName] = useState<string | null>(null)
14
+ const [dropTarget, setDropTarget] = useState<{ name: string; pos: 'above' | 'below' } | null>(null)
15
+
16
+ // Merge inventory sections with user-injected sections, sorted by order.
17
+ // Injected sections that don't exist in the original prompt appear here too,
18
+ // positioned by their order value.
19
+ const merged: Section[] = mergeSections(inv, cfg, blockedNames)
20
+
21
+ const toggleBlock = (name: string, currentlyBlocked: boolean): void => {
22
+ const list = (cfg.sections ?? []).slice()
23
+ if (currentlyBlocked) { const i = list.indexOf(name); if (i >= 0) list.splice(i, 1) }
24
+ else list.push(name)
25
+ scope.set('sections', list)
26
+ }
27
+ const startReplace = (name: string, original: string): void => {
28
+ // Echo the ORIGINAL prompt text so the user edits from the current value.
29
+ setEditing(name)
30
+ setDraft(replace[name] ?? original)
31
+ }
32
+ const commitReplace = (name: string): void => {
33
+ const next = { ...replace }
34
+ if (draft.trim() === '') delete next[name]
35
+ else next[name] = draft
36
+ scope.set('replace', next)
37
+ setEditing(null)
38
+ }
39
+ const restoreReplace = (name: string): void => {
40
+ const next = { ...replace }
41
+ delete next[name]
42
+ scope.set('replace', next)
43
+ }
44
+
45
+ // Persist the full ordered list (system + custom). We re-index every section
46
+ // with a sequential integer order (0, 1, 2, …) so the plugin panel order is
47
+ // the authoritative sort order. System sections keep empty text (so the
48
+ // server never freezes their dynamically generated content); custom sections
49
+ // keep their own text.
50
+ const persistOrder = (ordered: Section[]): void => {
51
+ const list = ordered.map((sec, i) => {
52
+ const existing = (cfg.inject ?? []).find((x) => x.name === sec.name)
53
+ const isCustom = sec.source === 'custom'
54
+ return { name: sec.name, order: i, text: isCustom ? (sec.text ?? '') : (existing?.text ?? ''), custom: isCustom }
55
+ })
56
+ scope.set('inject', list)
57
+ }
58
+
59
+ // Delete a custom (plugin-added) section: remove it from inject / sections /
60
+ // replace so it no longer appears in the panel at all.
61
+ const removeCustom = (name: string): void => {
62
+ const patch = removeSection(name, cfg)
63
+ scope.set('sections', patch.sections)
64
+ scope.set('inject', patch.inject)
65
+ scope.set('replace', patch.replace)
66
+ }
67
+
68
+ // Move a section up/down one slot (array-index swap), then re-index.
69
+ const moveOrder = (index: number, dir: -1 | 1): void => {
70
+ const target = index + dir
71
+ if (target < 0 || target >= merged.length) return
72
+ const next = merged.slice()
73
+ const [item] = next.splice(index, 1)
74
+ next.splice(target, 0, item)
75
+ persistOrder(next)
76
+ }
77
+
78
+ const onDragStart = (e: DragEvent, name: string): void => {
79
+ setDragName(name)
80
+ e.dataTransfer.effectAllowed = 'move'
81
+ try { e.dataTransfer.setData('text/plain', name) } catch { /* ignore */ }
82
+ }
83
+ const onDragOver = (e: DragEvent, name: string): void => {
84
+ if (!dragName || dragName === name) return
85
+ e.preventDefault()
86
+ e.dataTransfer.dropEffect = 'move'
87
+ const rect = e.currentTarget.getBoundingClientRect()
88
+ const pos = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
89
+ setDropTarget({ name, pos })
90
+ }
91
+ const onDrop = (e: DragEvent, target: Section): void => {
92
+ e.preventDefault()
93
+ if (!dragName || dragName === target.name) { setDragName(null); setDropTarget(null); return }
94
+ const dragged = merged.find((x) => x.name === dragName)
95
+ if (!dragged) { setDragName(null); setDropTarget(null); return }
96
+ const next = merged.filter((x) => x.name !== dragName)
97
+ const idx = next.findIndex((x) => x.name === target.name)
98
+ const pos = dropTarget?.name === target.name ? dropTarget.pos : 'below'
99
+ next.splice(pos === 'above' ? idx : idx + 1, 0, dragged)
100
+ persistOrder(next)
101
+ setDragName(null)
102
+ setDropTarget(null)
103
+ }
104
+ const onDragEnd = (): void => { setDragName(null); setDropTarget(null) }
105
+
106
+ // Add a new injected section, placed by its order hint, then re-index. The
107
+ // section is marked `custom` (the hidden marker) so it is identified as
108
+ // plugin-generated and thus deletable, and its own text is preserved.
109
+ const addSection = (name: string, order: number, text: string): void => {
110
+ const next = merged.slice()
111
+ const entry: Section = { name, order, text, active: true, replaced: false, source: 'custom' }
112
+ const existing = next.findIndex((x) => x.name === name)
113
+ if (existing >= 0) next[existing] = entry
114
+ else {
115
+ const idx = next.findIndex((x) => x.order > order)
116
+ if (idx < 0) next.push(entry)
117
+ else next.splice(idx, 0, entry)
118
+ }
119
+ persistOrder(next)
120
+ }
121
+
122
+ return h('div', { style: s.list }, [
123
+ merged.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
124
+ merged.map((sec, index) => {
125
+ const blocked = blockedNames.has(sec.name)
126
+ const isEditing = editing === sec.name
127
+ const isDragging = dragName === sec.name
128
+ const isDropTarget = dropTarget?.name === sec.name
129
+ const rowStyle = {
130
+ ...s.row,
131
+ ...(isDragging ? s.rowDragging : {}),
132
+ ...(isDropTarget && dropTarget.pos === 'above' ? s.rowDropAbove : {}),
133
+ ...(isDropTarget && dropTarget.pos === 'below' ? s.rowDropBelow : {}),
134
+ }
135
+ return h('div', {
136
+ key: sec.name,
137
+ style: rowStyle,
138
+ draggable: true,
139
+ onDragStart: (e: DragEvent) => onDragStart(e, sec.name),
140
+ onDragOver: (e: DragEvent) => onDragOver(e, sec.name),
141
+ onDrop: (e: DragEvent) => onDrop(e, sec),
142
+ onDragEnd,
143
+ }, [
144
+ h('span', { style: s.dragHandle, title: t('drag') }, '⠿'),
145
+ h('label', { style: s.switchWrap }, [
146
+ h('input', { type: 'checkbox', checked: blocked, onChange: () => toggleBlock(sec.name, blocked) }),
147
+ h('span', { style: blocked ? s.badgeBlocked : s.badgeOk }, blocked ? t('blockedOn') : t('blockedOff')),
148
+ ]),
149
+ h('div', { style: s.rowBody }, [
150
+ h('div', { style: s.rowTitle }, [
151
+ h('span', { style: s.code }, sec.name),
152
+ h('span', { style: s.orderTag }, '#' + index),
153
+ h('span', { style: sec.source === 'custom' ? s.badgeCustom : s.badgeSystem }, sec.source === 'custom' ? t('manual') : t('system')),
154
+ replace[sec.name] ? h('span', { style: s.badgeReplaced }, t('replaced')) : null,
155
+ ]),
156
+ isEditing
157
+ ? h('div', { style: s.editBox }, [
158
+ h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
159
+ h('div', { style: s.injectRow }, [
160
+ h('button', { style: s.mini, onClick: () => commitReplace(sec.name) }, t('save')),
161
+ h('button', { style: s.mini, onClick: () => setEditing(null) }, t('clearInput')),
162
+ sec.source !== 'custom' && replace[sec.name] ? h('button', { style: s.mini, onClick: () => restoreReplace(sec.name) }, t('restore')) : null,
163
+ ]),
164
+ ])
165
+ : h('div', { style: s.preview }, String(replace[sec.name] ?? sec.text ?? '').slice(0, 140) || (sec.source === 'custom' ? t('empty') : t('dynamic'))),
166
+ ]),
167
+ h('div', { style: s.arrowCol }, [
168
+ h('button', { style: s.arrow, disabled: index === 0, onClick: () => moveOrder(index, -1), title: t('moveUp') }, '↑'),
169
+ h('button', { style: s.arrow, disabled: index === merged.length - 1, onClick: () => moveOrder(index, 1), title: t('moveDown') }, '↓'),
170
+ ]),
171
+ isEditing ? null : h('button', { style: s.mini, onClick: () => startReplace(sec.name, String(sec.text ?? '')) }, t('replace')),
172
+ !isEditing && sec.source !== 'custom' && replace[sec.name] ? h('button', { style: s.mini, onClick: () => restoreReplace(sec.name) }, t('restore')) : null,
173
+ sec.source === 'custom' ? h('button', { style: s.mini, onClick: () => removeCustom(sec.name), title: t('delete') }, t('delete')) : null,
174
+ ])
175
+ }),
176
+ h('div', { style: s.injectBox }, [
177
+ h('div', { style: s.rowTitle }, t('injectNew')),
178
+ h(InjectForm, { onAdd: addSection, t }),
179
+ ]),
180
+ ])
181
+ }
182
+
183
+ function InjectForm({ onAdd, t }: { onAdd: (name: string, order: number, text: string) => void; t: Translate }): ReactElement {
184
+ const [name, setName] = useState('')
185
+ const [order, setOrder] = useState('120')
186
+ const [text, setText] = useState('')
187
+
188
+ const submit = (): void => {
189
+ if (!name.trim()) return
190
+ onAdd(name.trim(), Number(order) || 120, text)
191
+ setName(''); setText(''); setOrder('120')
192
+ }
193
+
194
+ return h('div', {}, [
195
+ h('div', { style: s.injectRow }, [
196
+ h('input', { style: { ...s.input, width: '22%' }, placeholder: t('name'), value: name, onChange: (e) => setName(e.target.value) }),
197
+ h('input', { style: { ...s.input, width: '12%' }, type: 'number', placeholder: t('order'), value: order, onChange: (e) => setOrder(e.target.value) }),
198
+ h('input', { style: { ...s.input, flex: 1 }, placeholder: t('text'), value: text, onChange: (e) => setText(e.target.value) }),
199
+ h('button', { style: s.mini, onClick: submit }, t('add')),
200
+ ]),
201
+ ])
202
+ }
@@ -1,48 +1,48 @@
1
- /** Tools tab: hide tools from the model catalog (blacklist only — check = hide). */
2
- import { createElement as h, type ReactElement } from 'react'
3
- import type { Config, Inventory, SettingsScope } from './types.ts'
4
- import type { Translate } from './locales.ts'
5
- import { s } from './styles.ts'
6
-
7
- export function ToolsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
8
- const tools = inv?.tools ?? []
9
- const exclude = cfg.tools?.exclude ?? []
10
-
11
- // Blacklist-only: a tool is hidden iff it is in the exclude list. Any stale
12
- // whitelist (include) is discarded on write so mode switching is gone.
13
- const hidden = (name: string): boolean => exclude.includes(name)
14
-
15
- const toggle = (name: string, currentlyHidden: boolean): void => {
16
- const next = exclude.slice()
17
- if (currentlyHidden) { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
18
- else next.push(name)
19
- scope.set('tools', { exclude: next, include: [] })
20
- }
21
-
22
- const setAll = (hideAll: boolean): void => {
23
- scope.set('tools', { exclude: hideAll ? tools.map((tool) => tool.name) : [], include: [] })
24
- }
25
-
26
- return h('div', {}, [
27
- h('div', { style: s.bar }, [
28
- h('button', { style: s.mini, onClick: () => setAll(true) }, t('selectAll')),
29
- h('button', { style: s.mini, onClick: () => setAll(false) }, t('selectNone')),
30
- ]),
31
- h('div', { style: s.list }, [
32
- tools.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
33
- tools.map((tool) => {
34
- const isHidden = hidden(tool.name)
35
- return h('div', { key: tool.name, style: s.row }, [
36
- h('label', { style: s.switchWrap }, [
37
- h('input', { type: 'checkbox', checked: isHidden, onChange: () => toggle(tool.name, isHidden) }),
38
- h('span', { style: isHidden ? s.badgeBlocked : s.badgeOk }, isHidden ? t('hiddenOn') : t('hiddenOff')),
39
- ]),
40
- h('div', { style: s.rowBody }, [
41
- h('div', { style: s.rowTitle }, h('span', { style: s.code }, tool.name)),
42
- h('div', { style: s.preview }, String(tool.description ?? '').slice(0, 140)),
43
- ]),
44
- ])
45
- }),
46
- ]),
47
- ])
48
- }
1
+ /** Tools tab: hide tools from the model catalog (blacklist only — check = hide). */
2
+ import { createElement as h, type ReactElement } from 'react'
3
+ import type { Config, Inventory, SettingsScope } from './types.ts'
4
+ import type { Translate } from './locales.ts'
5
+ import { s } from './styles.ts'
6
+
7
+ export function ToolsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
8
+ const tools = inv?.tools ?? []
9
+ const exclude = cfg.tools?.exclude ?? []
10
+
11
+ // Blacklist-only: a tool is hidden iff it is in the exclude list. Any stale
12
+ // whitelist (include) is discarded on write so mode switching is gone.
13
+ const hidden = (name: string): boolean => exclude.includes(name)
14
+
15
+ const toggle = (name: string, currentlyHidden: boolean): void => {
16
+ const next = exclude.slice()
17
+ if (currentlyHidden) { const i = next.indexOf(name); if (i >= 0) next.splice(i, 1) }
18
+ else next.push(name)
19
+ scope.set('tools', { exclude: next, include: [] })
20
+ }
21
+
22
+ const setAll = (hideAll: boolean): void => {
23
+ scope.set('tools', { exclude: hideAll ? tools.map((tool) => tool.name) : [], include: [] })
24
+ }
25
+
26
+ return h('div', {}, [
27
+ h('div', { style: s.bar }, [
28
+ h('button', { style: s.mini, onClick: () => setAll(true) }, t('selectAll')),
29
+ h('button', { style: s.mini, onClick: () => setAll(false) }, t('selectNone')),
30
+ ]),
31
+ h('div', { style: s.list }, [
32
+ tools.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
33
+ tools.map((tool) => {
34
+ const isHidden = hidden(tool.name)
35
+ return h('div', { key: tool.name, style: s.row }, [
36
+ h('label', { style: s.switchWrap }, [
37
+ h('input', { type: 'checkbox', checked: isHidden, onChange: () => toggle(tool.name, isHidden) }),
38
+ h('span', { style: isHidden ? s.badgeBlocked : s.badgeOk }, isHidden ? t('hiddenOn') : t('hiddenOff')),
39
+ ]),
40
+ h('div', { style: s.rowBody }, [
41
+ h('div', { style: s.rowTitle }, h('span', { style: s.code }, tool.name)),
42
+ h('div', { style: s.preview }, String(tool.description ?? '').slice(0, 140)),
43
+ ]),
44
+ ])
45
+ }),
46
+ ]),
47
+ ])
48
+ }