@skaile/forge-settings 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.2
4
+
5
+ - `<ForgeProvidersPanel>` lists providers as stacked rows instead of a six-column table: name and badge, then one muted line with id, preset and base URL, the credential picker below, and Test / Edit / Delete / Enabled on the right. It fits a settings column without clipping the actions or wrapping the base URL per character. Test ids are unchanged.
6
+
7
+ ## 1.3.1
8
+
9
+ - `<ForgeProvidersPanel>` takes an optional `note` prop: a string replaces the line above the provider list, `false` hides it (Add provider stays, right-aligned). Absent keeps today's text, so existing hosts see no change.
10
+
3
11
  ## 1.3.0
4
12
 
5
13
  - Model roles (`/server` and `/vue`, browser-safe): `MODEL_ROLE_KINDS` (`chat`, `decision`, `image`, `tts`, `stt`), `ModelRoleDefinition`, `STANDARD_MODEL_ROLES` (`decision`, `image`, `tts`, `stt`), `validateModelRoleAssignments` (issues keyed by role id), `resolveModelRole` and `modelRoleAliases` (feeds `resolveRegistryLanguageModel({ roles })`). A dangling assignment throws `ModelResolutionError` (`unknown_selector`). omp naming: `decision` = `judge`, `tts` = `speech`, `stt` = `dictation`.
package/README.md CHANGED
@@ -86,6 +86,8 @@ The panel (`PUT|DELETE <writeUrl>/:id`, `GET <writeUrl>/detected`, `GET <provide
86
86
 
87
87
  Without URLs, pass `:client` (a `ProvidersPanelClient`: `list`, `detected`, `presets`, `upsert`, `remove`, `environment`, `updateEnvironment`, `models`, `test`) and the panel talks through it; the URL props then become optional. A passed client also turns on the per-provider Test button. `urlProvidersClient({ providersUrl, providersWriteUrl, … })` is the client the URL props build; its `models(id)` and `test(id)` call `GET|POST <writeUrl>/:id/models|test`, routes only a host that wants those features serves.
88
88
 
89
+ `note` sets the line above the list: absent keeps the panel's text for `layer`, a string replaces it, `false` hides it (the Add provider button stays, right-aligned).
90
+
89
91
  ## Model roles
90
92
 
91
93
  A *model role* is a named purpose a host picks a model for. The host declares roles and stores the assignments (role id to selector); this package supplies the vocabulary, validation, resolution and a picker.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
3
  "configKey": "forgeSettings",
4
- "version": "1.3.0",
4
+ "version": "1.3.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
@@ -20,6 +20,8 @@ type __VLS_Props = {
20
20
  layer: ProvidersPanelLayer;
21
21
  /** Disables add, edit and delete. */
22
22
  readOnly?: boolean;
23
+ /** The line above the list. Absent: the default text for `layer`; a string replaces it; `false` hides it. */
24
+ note?: string | false;
23
25
  };
24
26
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
25
27
  changed: () => any;
@@ -27,5 +29,6 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
27
29
  onChanged?: (() => any) | undefined;
28
30
  }>, {
29
31
  environmentEnabled: boolean;
32
+ note: string | false;
30
33
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
31
34
  export default _default;
@@ -29,10 +29,9 @@
29
29
  <p v-if="detectedError" class="text-xs text-error-500">{{ detectedError }}</p>
30
30
  </div>
31
31
 
32
- <div class="flex items-center gap-2">
33
- <p class="text-xs text-foreground-muted leading-relaxed flex-1">
34
- {{ props.layer === "workspace" ? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited." : "The instance registry, $FORGE_HOME/.skaile/providers.yml." }}
35
- Keys stay in the environment; an entry only references them.
32
+ <div class="flex items-center gap-2" :class="{ 'justify-end': noteText === null }">
33
+ <p v-if="noteText !== null" class="text-xs text-foreground-muted leading-relaxed flex-1">
34
+ {{ noteText }}
36
35
  </p>
37
36
  <UButton
38
37
  v-if="!props.readOnly"
@@ -53,191 +52,176 @@
53
52
  This registry has no providers yet.
54
53
  </div>
55
54
 
56
- <div v-else class="overflow-x-auto rounded-lg border border-surface-border">
57
- <table class="w-full text-xs">
58
- <thead class="bg-surface-ground/50 text-foreground-muted">
59
- <tr class="text-left">
60
- <th class="px-3 py-2 font-medium">Provider</th>
61
- <th class="px-3 py-2 font-medium">Preset / surface</th>
62
- <th class="px-3 py-2 font-medium">Base URL</th>
63
- <th class="px-3 py-2 font-medium">Credential</th>
64
- <th class="px-3 py-2 font-medium">Enabled</th>
65
- <th class="px-3 py-2" />
66
- </tr>
67
- </thead>
68
- <tbody>
69
- <tr
70
- v-for="row in rows"
71
- :key="row.id"
72
- :data-testid="`provider-row-${row.id}`"
73
- class="border-t border-surface-border align-top"
74
- :class="row.enabled ? '' : 'opacity-60'"
75
- >
76
- <td class="px-3 py-2">
77
- <div class="flex items-center gap-1.5 flex-wrap">
78
- <span class="font-medium text-foreground">{{ row.name }}</span>
79
- <UBadge
80
- :data-testid="`provider-badge-${row.id}`"
81
- size="sm"
82
- variant="subtle"
83
- color="neutral"
84
- >{{ row.badge }}</UBadge>
85
- <Icon v-if="row.locked" name="lucide:lock" class="text-foreground-subtle shrink-0" />
86
- </div>
87
- <code v-if="row.name !== row.id" class="font-mono text-foreground-subtle">{{ row.id }}</code>
88
- <p v-if="row.inheritedNote" class="text-foreground-subtle italic">{{ row.inheritedNote }}</p>
89
- </td>
90
- <td class="px-3 py-2 font-mono text-foreground-muted">{{ row.origin }}</td>
91
- <td class="px-3 py-2 font-mono text-foreground-muted break-all">{{ row.baseUrl ?? "\u2014" }}</td>
92
- <td class="px-3 py-2 min-w-[15rem] space-y-1">
93
- <USelect
94
- v-if="row.needsCredential"
95
- :model-value="row.credentialValue"
96
- :items="row.credentialOptions"
97
- placeholder="Choose a credential"
98
- :disabled="!row.canEditCredential || rowBusy === row.id"
99
- :data-testid="`provider-credential-${row.id}`"
100
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
101
- @update:model-value="onCredentialSelect(row, String($event ?? ''))"
102
- />
103
- <p :class="row.configured ? 'text-foreground-subtle' : 'text-amber-600 dark:text-amber-400'" class="italic">
104
- {{ row.status }}
105
- </p>
106
-
107
- <!-- Inline key editor -->
108
- <div
109
- v-if="keyEditing === row.id"
110
- :data-testid="`provider-credential-enter-key-${row.id}`"
111
- class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
55
+ <!-- One stacked row per provider: fits a settings column; a table squeezed the base URL and clipped the actions. -->
56
+ <ul v-else class="rounded-lg border border-surface-border divide-y divide-surface-border text-xs">
57
+ <li
58
+ v-for="row in rows"
59
+ :key="row.id"
60
+ :data-testid="`provider-row-${row.id}`"
61
+ class="p-3 space-y-2"
62
+ :class="row.enabled ? '' : 'opacity-60'"
63
+ >
64
+ <div class="flex items-start gap-3">
65
+ <div class="flex-1 min-w-0">
66
+ <div class="flex items-center gap-1.5 flex-wrap">
67
+ <span class="text-sm font-medium text-foreground">{{ row.name }}</span>
68
+ <UBadge
69
+ :data-testid="`provider-badge-${row.id}`"
70
+ size="sm"
71
+ variant="subtle"
72
+ color="neutral"
73
+ >{{ row.badge }}</UBadge>
74
+ <Icon v-if="row.locked" name="lucide:lock" class="text-foreground-subtle shrink-0" />
75
+ </div>
76
+ <p class="font-mono text-foreground-subtle truncate" :title="row.baseUrl ?? void 0">{{ rowMeta(row) }}</p>
77
+ <p v-if="row.inheritedNote" class="text-foreground-subtle italic">{{ row.inheritedNote }}</p>
78
+ </div>
79
+ <div class="flex items-center gap-1 shrink-0">
80
+ <UButton
81
+ v-if="canTest"
82
+ size="sm"
83
+ variant="ghost"
84
+ :loading="testBusy === row.id"
85
+ :disabled="testBusy !== null"
86
+ :data-testid="`provider-test-${row.id}`"
87
+ @click="testProvider(row)"
88
+ >
89
+ <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
90
+ </UButton>
91
+ <UButton
92
+ v-if="row.canReset"
93
+ size="sm"
94
+ variant="ghost"
95
+ :loading="rowBusy === row.id"
96
+ :data-testid="`provider-reset-${row.id}`"
97
+ @click="resetPatch(row)"
98
+ >
99
+ <Icon name="lucide:undo-2" class="mr-1.5 w-3.5 h-3.5" />Reset
100
+ </UButton>
101
+ <template v-if="row.editable">
102
+ <UButton
103
+ size="sm"
104
+ variant="ghost"
105
+ :aria-label="`Edit ${row.id}`"
106
+ :data-testid="`provider-edit-${row.id}`"
107
+ @click="openEdit(row)"
112
108
  >
113
- <UInput
114
- v-model="keyForm.name"
115
- placeholder="PROVIDER_API_KEY"
116
- :data-testid="`provider-key-name-${row.id}`"
117
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
118
- />
119
- <UInput
120
- v-model="keyForm.value"
121
- type="password"
122
- autocomplete="off"
123
- placeholder="Key"
124
- :data-testid="`provider-key-value-${row.id}`"
125
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
126
- />
127
- <p v-if="keyError" class="text-error-500">{{ keyError }}</p>
128
- <div class="flex justify-end gap-1">
129
- <UButton size="xs" variant="ghost" :data-testid="`provider-key-cancel-${row.id}`" @click="cancelKeyEditor()">Cancel</UButton>
130
- <UButton
131
- size="xs"
132
- color="primary"
133
- :loading="rowBusy === row.id"
134
- :data-testid="`provider-key-save-${row.id}`"
135
- @click="saveKeyEditor()"
136
- >Save</UButton>
137
- </div>
138
- </div>
139
-
140
- <!-- Inline reference editor -->
141
- <div
142
- v-if="refEditing === row.id"
143
- :data-testid="`provider-credential-ref-${row.id}`"
144
- class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
109
+ <Icon name="lucide:pencil" class="w-3.5 h-3.5" />
110
+ </UButton>
111
+ <UButton
112
+ size="sm"
113
+ variant="ghost"
114
+ color="error"
115
+ :aria-label="`Delete ${row.id}`"
116
+ :data-testid="`provider-delete-${row.id}`"
117
+ @click="requestDelete(row)"
145
118
  >
146
- <UInput
147
- v-model="refForm.ref"
148
- placeholder="keyring:forge/name · cmd:… · platform:…"
149
- :data-testid="`provider-ref-input-${row.id}`"
150
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
151
- />
152
- <p v-if="refError" class="text-error-500">{{ refError }}</p>
153
- <div class="flex justify-end gap-1">
154
- <UButton size="xs" variant="ghost" :data-testid="`provider-ref-cancel-${row.id}`" @click="cancelRefEditor()">Cancel</UButton>
155
- <UButton
156
- size="xs"
157
- color="primary"
158
- :loading="rowBusy === row.id"
159
- :data-testid="`provider-ref-save-${row.id}`"
160
- @click="saveRefEditor()"
161
- >Save</UButton>
162
- </div>
163
- </div>
119
+ <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
120
+ </UButton>
121
+ </template>
122
+ <USwitch
123
+ :model-value="row.enabled"
124
+ :disabled="!row.canToggle || rowBusy === row.id"
125
+ :aria-label="`Enable ${row.id}`"
126
+ :data-testid="`provider-enabled-${row.id}`"
127
+ class="ml-1"
128
+ @update:model-value="setEnabled(row, Boolean($event))"
129
+ />
130
+ </div>
131
+ </div>
164
132
 
165
- <p
166
- v-for="notice in row.overrideNotices"
167
- :key="notice"
168
- :data-testid="`provider-override-notice-${row.id}`"
169
- class="text-foreground-subtle"
170
- >{{ notice }}</p>
171
- <p
172
- v-if="testResults[row.id]"
173
- :data-testid="`provider-test-result-${row.id}`"
174
- :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
175
- >{{ testResults[row.id]?.message }}</p>
176
- <p
177
- v-if="rowError?.id === row.id"
178
- :data-testid="`provider-row-error-${row.id}`"
179
- class="text-error-500"
180
- >{{ rowError.message }}</p>
181
- </td>
182
- <td class="px-3 py-2">
183
- <USwitch
184
- :model-value="row.enabled"
185
- :disabled="!row.canToggle || rowBusy === row.id"
186
- :aria-label="`Enable ${row.id}`"
187
- :data-testid="`provider-enabled-${row.id}`"
188
- @update:model-value="setEnabled(row, Boolean($event))"
189
- />
190
- </td>
191
- <td class="px-3 py-2">
192
- <div class="flex items-center justify-end gap-1">
193
- <UButton
194
- v-if="canTest"
195
- size="sm"
196
- variant="ghost"
197
- :loading="testBusy === row.id"
198
- :disabled="testBusy !== null"
199
- :data-testid="`provider-test-${row.id}`"
200
- @click="testProvider(row)"
201
- >
202
- <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
203
- </UButton>
204
- <UButton
205
- v-if="row.canReset"
206
- size="sm"
207
- variant="ghost"
208
- :loading="rowBusy === row.id"
209
- :data-testid="`provider-reset-${row.id}`"
210
- @click="resetPatch(row)"
211
- >
212
- <Icon name="lucide:undo-2" class="mr-1.5 w-3.5 h-3.5" />Reset
213
- </UButton>
214
- <template v-if="row.editable">
215
- <UButton
216
- size="sm"
217
- variant="ghost"
218
- :aria-label="`Edit ${row.id}`"
219
- :data-testid="`provider-edit-${row.id}`"
220
- @click="openEdit(row)"
221
- >
222
- <Icon name="lucide:pencil" class="w-3.5 h-3.5" />
223
- </UButton>
224
- <UButton
225
- size="sm"
226
- variant="ghost"
227
- color="error"
228
- :aria-label="`Delete ${row.id}`"
229
- :data-testid="`provider-delete-${row.id}`"
230
- @click="requestDelete(row)"
231
- >
232
- <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
233
- </UButton>
234
- </template>
235
- </div>
236
- </td>
237
- </tr>
238
- </tbody>
239
- </table>
240
- </div>
133
+ <div class="space-y-1 max-w-md">
134
+ <USelect
135
+ v-if="row.needsCredential"
136
+ :model-value="row.credentialValue"
137
+ :items="row.credentialOptions"
138
+ placeholder="Choose a credential"
139
+ :disabled="!row.canEditCredential || rowBusy === row.id"
140
+ :data-testid="`provider-credential-${row.id}`"
141
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
142
+ @update:model-value="onCredentialSelect(row, String($event ?? ''))"
143
+ />
144
+ <p :class="row.configured ? 'text-foreground-subtle' : 'text-amber-600 dark:text-amber-400'" class="italic">
145
+ {{ row.status }}
146
+ </p>
147
+
148
+ <!-- Inline key editor -->
149
+ <div
150
+ v-if="keyEditing === row.id"
151
+ :data-testid="`provider-credential-enter-key-${row.id}`"
152
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
153
+ >
154
+ <UInput
155
+ v-model="keyForm.name"
156
+ placeholder="PROVIDER_API_KEY"
157
+ :data-testid="`provider-key-name-${row.id}`"
158
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
159
+ />
160
+ <UInput
161
+ v-model="keyForm.value"
162
+ type="password"
163
+ autocomplete="off"
164
+ placeholder="Key"
165
+ :data-testid="`provider-key-value-${row.id}`"
166
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
167
+ />
168
+ <p v-if="keyError" class="text-error-500">{{ keyError }}</p>
169
+ <div class="flex justify-end gap-1">
170
+ <UButton size="xs" variant="ghost" :data-testid="`provider-key-cancel-${row.id}`" @click="cancelKeyEditor()">Cancel</UButton>
171
+ <UButton
172
+ size="xs"
173
+ color="primary"
174
+ :loading="rowBusy === row.id"
175
+ :data-testid="`provider-key-save-${row.id}`"
176
+ @click="saveKeyEditor()"
177
+ >Save</UButton>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Inline reference editor -->
182
+ <div
183
+ v-if="refEditing === row.id"
184
+ :data-testid="`provider-credential-ref-${row.id}`"
185
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
186
+ >
187
+ <UInput
188
+ v-model="refForm.ref"
189
+ placeholder="keyring:forge/name · cmd:… · platform:…"
190
+ :data-testid="`provider-ref-input-${row.id}`"
191
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
192
+ />
193
+ <p v-if="refError" class="text-error-500">{{ refError }}</p>
194
+ <div class="flex justify-end gap-1">
195
+ <UButton size="xs" variant="ghost" :data-testid="`provider-ref-cancel-${row.id}`" @click="cancelRefEditor()">Cancel</UButton>
196
+ <UButton
197
+ size="xs"
198
+ color="primary"
199
+ :loading="rowBusy === row.id"
200
+ :data-testid="`provider-ref-save-${row.id}`"
201
+ @click="saveRefEditor()"
202
+ >Save</UButton>
203
+ </div>
204
+ </div>
205
+
206
+ <p
207
+ v-for="notice in row.overrideNotices"
208
+ :key="notice"
209
+ :data-testid="`provider-override-notice-${row.id}`"
210
+ class="text-foreground-subtle"
211
+ >{{ notice }}</p>
212
+ <p
213
+ v-if="testResults[row.id]"
214
+ :data-testid="`provider-test-result-${row.id}`"
215
+ :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
216
+ >{{ testResults[row.id]?.message }}</p>
217
+ <p
218
+ v-if="rowError?.id === row.id"
219
+ :data-testid="`provider-row-error-${row.id}`"
220
+ class="text-error-500"
221
+ >{{ rowError.message }}</p>
222
+ </div>
223
+ </li>
224
+ </ul>
241
225
 
242
226
  <!-- Add / edit dialog -->
243
227
  <UModal
@@ -390,6 +374,8 @@
390
374
  </template>
391
375
 
392
376
  <script setup>
377
+ import { computed } from "vue";
378
+ import { providersPanelNote, providersPanelRowMeta as rowMeta } from "../vue/providers-panel-model";
393
379
  import { useProvidersPanel } from "../vue/useProvidersPanel";
394
380
  const props = defineProps({
395
381
  client: { type: Object, required: false },
@@ -399,8 +385,10 @@ const props = defineProps({
399
385
  environmentUrl: { type: String, required: false },
400
386
  environmentEnabled: { type: Boolean, required: false, default: void 0 },
401
387
  layer: { type: String, required: true },
402
- readOnly: { type: Boolean, required: false }
388
+ readOnly: { type: Boolean, required: false },
389
+ note: { type: [String, Boolean], required: false, default: void 0 }
403
390
  });
391
+ const noteText = computed(() => providersPanelNote(props.note, props.layer));
404
392
  const emit = defineEmits(["changed"]);
405
393
  const {
406
394
  rows,
@@ -20,6 +20,8 @@ type __VLS_Props = {
20
20
  layer: ProvidersPanelLayer;
21
21
  /** Disables add, edit and delete. */
22
22
  readOnly?: boolean;
23
+ /** The line above the list. Absent: the default text for `layer`; a string replaces it; `false` hides it. */
24
+ note?: string | false;
23
25
  };
24
26
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
25
27
  changed: () => any;
@@ -27,5 +29,6 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
27
29
  onChanged?: (() => any) | undefined;
28
30
  }>, {
29
31
  environmentEnabled: boolean;
32
+ note: string | false;
30
33
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
31
34
  export default _default;
@@ -38,6 +38,11 @@ export interface ProvidersPanelProps {
38
38
  layer: ProvidersPanelLayer;
39
39
  /** Disables every write (non-admin at instance scope; provider lock). */
40
40
  readOnly?: boolean;
41
+ /**
42
+ * The line above the provider list. Absent: the panel's own text for
43
+ * `layer`; a string replaces it; `false` hides it (Add provider stays).
44
+ */
45
+ note?: string | false;
41
46
  }
42
47
  /** Emits both variants declare. */
43
48
  export type ProvidersPanelEmit = (event: "changed") => void;
@@ -142,6 +147,18 @@ export declare const CREDENTIAL_OTHER_REF = "__other_ref__";
142
147
  /** Registry ids the write routes accept (`isSafeProviderId` upstream). */
143
148
  export declare const PROVIDER_ID_PATTERN: RegExp;
144
149
  export declare const DEFAULT_PRESETS_URL = "/api/providers/presets";
150
+ /** The line `ForgeProvidersPanel` shows above its list; `null` = none. */
151
+ export declare function providersPanelNote(note: string | false | undefined, layer: ProvidersPanelLayer): string | null;
152
+ /**
153
+ * The muted line under a provider's name: its id (when the name differs), its
154
+ * preset or surface and its base URL, without repeating a value.
155
+ */
156
+ export declare function providersPanelRowMeta(row: {
157
+ id: string;
158
+ name: string;
159
+ origin: string;
160
+ baseUrl?: string | null;
161
+ }): string;
145
162
  /** An `env:NAME` credential reference; group 1 is the variable name. */
146
163
  export declare const ENV_REF_PATTERN: RegExp;
147
164
  /** The credential kind a written reference gets when the row states none. */
@@ -3,6 +3,15 @@ export const CREDENTIAL_ENTER_KEY = "__enter_key__";
3
3
  export const CREDENTIAL_OTHER_REF = "__other_ref__";
4
4
  export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
5
5
  export const DEFAULT_PRESETS_URL = "/api/providers/presets";
6
+ export function providersPanelNote(note, layer) {
7
+ if (note === false) return null;
8
+ if (note !== void 0) return note;
9
+ return layer === "workspace" ? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited. Keys stay in the environment; an entry only references them." : "The instance registry, $FORGE_HOME/.skaile/providers.yml. Keys stay in the environment; an entry only references them.";
10
+ }
11
+ export function providersPanelRowMeta(row) {
12
+ const parts = [row.name !== row.id ? row.id : null, row.origin, row.baseUrl ?? null];
13
+ return [...new Set(parts.filter((part) => !!part))].join(" \xB7 ");
14
+ }
6
15
  export const ENV_REF_PATTERN = /^env:(.+)$/;
7
16
  export const FALLBACK_CREDENTIAL_KIND = "api_key_header";
8
17
  export const ENV_SOURCE_LABEL = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Provider and settings operations for Skaile forge apps: provider registry layers, credential store, env layers, model discovery, the providers panel, and editor / AI SDK model adapters.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -29,12 +29,9 @@
29
29
  <p v-if="detectedError" class="text-xs text-error-500">{{ detectedError }}</p>
30
30
  </div>
31
31
 
32
- <div class="flex items-center gap-2">
33
- <p class="text-xs text-foreground-muted leading-relaxed flex-1">
34
- {{ props.layer === 'workspace'
35
- ? 'Entries of this workspace\'s providers.yml can be changed here; instance entries are inherited.'
36
- : 'The instance registry, $FORGE_HOME/.skaile/providers.yml.' }}
37
- Keys stay in the environment; an entry only references them.
32
+ <div class="flex items-center gap-2" :class="{ 'justify-end': noteText === null }">
33
+ <p v-if="noteText !== null" class="text-xs text-foreground-muted leading-relaxed flex-1">
34
+ {{ noteText }}
38
35
  </p>
39
36
  <UButton
40
37
  v-if="!props.readOnly"
@@ -55,191 +52,176 @@
55
52
  This registry has no providers yet.
56
53
  </div>
57
54
 
58
- <div v-else class="overflow-x-auto rounded-lg border border-surface-border">
59
- <table class="w-full text-xs">
60
- <thead class="bg-surface-ground/50 text-foreground-muted">
61
- <tr class="text-left">
62
- <th class="px-3 py-2 font-medium">Provider</th>
63
- <th class="px-3 py-2 font-medium">Preset / surface</th>
64
- <th class="px-3 py-2 font-medium">Base URL</th>
65
- <th class="px-3 py-2 font-medium">Credential</th>
66
- <th class="px-3 py-2 font-medium">Enabled</th>
67
- <th class="px-3 py-2" />
68
- </tr>
69
- </thead>
70
- <tbody>
71
- <tr
72
- v-for="row in rows"
73
- :key="row.id"
74
- :data-testid="`provider-row-${row.id}`"
75
- class="border-t border-surface-border align-top"
76
- :class="row.enabled ? '' : 'opacity-60'"
77
- >
78
- <td class="px-3 py-2">
79
- <div class="flex items-center gap-1.5 flex-wrap">
80
- <span class="font-medium text-foreground">{{ row.name }}</span>
81
- <UBadge
82
- :data-testid="`provider-badge-${row.id}`"
83
- size="sm"
84
- variant="subtle"
85
- color="neutral"
86
- >{{ row.badge }}</UBadge>
87
- <Icon v-if="row.locked" name="lucide:lock" class="text-foreground-subtle shrink-0" />
88
- </div>
89
- <code v-if="row.name !== row.id" class="font-mono text-foreground-subtle">{{ row.id }}</code>
90
- <p v-if="row.inheritedNote" class="text-foreground-subtle italic">{{ row.inheritedNote }}</p>
91
- </td>
92
- <td class="px-3 py-2 font-mono text-foreground-muted">{{ row.origin }}</td>
93
- <td class="px-3 py-2 font-mono text-foreground-muted break-all">{{ row.baseUrl ?? '—' }}</td>
94
- <td class="px-3 py-2 min-w-[15rem] space-y-1">
95
- <USelect
96
- v-if="row.needsCredential"
97
- :model-value="row.credentialValue"
98
- :items="row.credentialOptions"
99
- placeholder="Choose a credential"
100
- :disabled="!row.canEditCredential || rowBusy === row.id"
101
- :data-testid="`provider-credential-${row.id}`"
102
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
103
- @update:model-value="onCredentialSelect(row, String($event ?? ''))"
104
- />
105
- <p :class="row.configured ? 'text-foreground-subtle' : 'text-amber-600 dark:text-amber-400'" class="italic">
106
- {{ row.status }}
107
- </p>
108
-
109
- <!-- Inline key editor -->
110
- <div
111
- v-if="keyEditing === row.id"
112
- :data-testid="`provider-credential-enter-key-${row.id}`"
113
- class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
55
+ <!-- One stacked row per provider: fits a settings column; a table squeezed the base URL and clipped the actions. -->
56
+ <ul v-else class="rounded-lg border border-surface-border divide-y divide-surface-border text-xs">
57
+ <li
58
+ v-for="row in rows"
59
+ :key="row.id"
60
+ :data-testid="`provider-row-${row.id}`"
61
+ class="p-3 space-y-2"
62
+ :class="row.enabled ? '' : 'opacity-60'"
63
+ >
64
+ <div class="flex items-start gap-3">
65
+ <div class="flex-1 min-w-0">
66
+ <div class="flex items-center gap-1.5 flex-wrap">
67
+ <span class="text-sm font-medium text-foreground">{{ row.name }}</span>
68
+ <UBadge
69
+ :data-testid="`provider-badge-${row.id}`"
70
+ size="sm"
71
+ variant="subtle"
72
+ color="neutral"
73
+ >{{ row.badge }}</UBadge>
74
+ <Icon v-if="row.locked" name="lucide:lock" class="text-foreground-subtle shrink-0" />
75
+ </div>
76
+ <p class="font-mono text-foreground-subtle truncate" :title="row.baseUrl ?? undefined">{{ rowMeta(row) }}</p>
77
+ <p v-if="row.inheritedNote" class="text-foreground-subtle italic">{{ row.inheritedNote }}</p>
78
+ </div>
79
+ <div class="flex items-center gap-1 shrink-0">
80
+ <UButton
81
+ v-if="canTest"
82
+ size="sm"
83
+ variant="ghost"
84
+ :loading="testBusy === row.id"
85
+ :disabled="testBusy !== null"
86
+ :data-testid="`provider-test-${row.id}`"
87
+ @click="testProvider(row)"
88
+ >
89
+ <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
90
+ </UButton>
91
+ <UButton
92
+ v-if="row.canReset"
93
+ size="sm"
94
+ variant="ghost"
95
+ :loading="rowBusy === row.id"
96
+ :data-testid="`provider-reset-${row.id}`"
97
+ @click="resetPatch(row)"
98
+ >
99
+ <Icon name="lucide:undo-2" class="mr-1.5 w-3.5 h-3.5" />Reset
100
+ </UButton>
101
+ <template v-if="row.editable">
102
+ <UButton
103
+ size="sm"
104
+ variant="ghost"
105
+ :aria-label="`Edit ${row.id}`"
106
+ :data-testid="`provider-edit-${row.id}`"
107
+ @click="openEdit(row)"
114
108
  >
115
- <UInput
116
- v-model="keyForm.name"
117
- placeholder="PROVIDER_API_KEY"
118
- :data-testid="`provider-key-name-${row.id}`"
119
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
120
- />
121
- <UInput
122
- v-model="keyForm.value"
123
- type="password"
124
- autocomplete="off"
125
- placeholder="Key"
126
- :data-testid="`provider-key-value-${row.id}`"
127
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
128
- />
129
- <p v-if="keyError" class="text-error-500">{{ keyError }}</p>
130
- <div class="flex justify-end gap-1">
131
- <UButton size="xs" variant="ghost" :data-testid="`provider-key-cancel-${row.id}`" @click="cancelKeyEditor()">Cancel</UButton>
132
- <UButton
133
- size="xs"
134
- color="primary"
135
- :loading="rowBusy === row.id"
136
- :data-testid="`provider-key-save-${row.id}`"
137
- @click="saveKeyEditor()"
138
- >Save</UButton>
139
- </div>
140
- </div>
141
-
142
- <!-- Inline reference editor -->
143
- <div
144
- v-if="refEditing === row.id"
145
- :data-testid="`provider-credential-ref-${row.id}`"
146
- class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
109
+ <Icon name="lucide:pencil" class="w-3.5 h-3.5" />
110
+ </UButton>
111
+ <UButton
112
+ size="sm"
113
+ variant="ghost"
114
+ color="error"
115
+ :aria-label="`Delete ${row.id}`"
116
+ :data-testid="`provider-delete-${row.id}`"
117
+ @click="requestDelete(row)"
147
118
  >
148
- <UInput
149
- v-model="refForm.ref"
150
- placeholder="keyring:forge/name · cmd:… · platform:…"
151
- :data-testid="`provider-ref-input-${row.id}`"
152
- class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
153
- />
154
- <p v-if="refError" class="text-error-500">{{ refError }}</p>
155
- <div class="flex justify-end gap-1">
156
- <UButton size="xs" variant="ghost" :data-testid="`provider-ref-cancel-${row.id}`" @click="cancelRefEditor()">Cancel</UButton>
157
- <UButton
158
- size="xs"
159
- color="primary"
160
- :loading="rowBusy === row.id"
161
- :data-testid="`provider-ref-save-${row.id}`"
162
- @click="saveRefEditor()"
163
- >Save</UButton>
164
- </div>
165
- </div>
119
+ <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
120
+ </UButton>
121
+ </template>
122
+ <USwitch
123
+ :model-value="row.enabled"
124
+ :disabled="!row.canToggle || rowBusy === row.id"
125
+ :aria-label="`Enable ${row.id}`"
126
+ :data-testid="`provider-enabled-${row.id}`"
127
+ class="ml-1"
128
+ @update:model-value="setEnabled(row, Boolean($event))"
129
+ />
130
+ </div>
131
+ </div>
166
132
 
167
- <p
168
- v-for="notice in row.overrideNotices"
169
- :key="notice"
170
- :data-testid="`provider-override-notice-${row.id}`"
171
- class="text-foreground-subtle"
172
- >{{ notice }}</p>
173
- <p
174
- v-if="testResults[row.id]"
175
- :data-testid="`provider-test-result-${row.id}`"
176
- :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
177
- >{{ testResults[row.id]?.message }}</p>
178
- <p
179
- v-if="rowError?.id === row.id"
180
- :data-testid="`provider-row-error-${row.id}`"
181
- class="text-error-500"
182
- >{{ rowError.message }}</p>
183
- </td>
184
- <td class="px-3 py-2">
185
- <USwitch
186
- :model-value="row.enabled"
187
- :disabled="!row.canToggle || rowBusy === row.id"
188
- :aria-label="`Enable ${row.id}`"
189
- :data-testid="`provider-enabled-${row.id}`"
190
- @update:model-value="setEnabled(row, Boolean($event))"
191
- />
192
- </td>
193
- <td class="px-3 py-2">
194
- <div class="flex items-center justify-end gap-1">
195
- <UButton
196
- v-if="canTest"
197
- size="sm"
198
- variant="ghost"
199
- :loading="testBusy === row.id"
200
- :disabled="testBusy !== null"
201
- :data-testid="`provider-test-${row.id}`"
202
- @click="testProvider(row)"
203
- >
204
- <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
205
- </UButton>
206
- <UButton
207
- v-if="row.canReset"
208
- size="sm"
209
- variant="ghost"
210
- :loading="rowBusy === row.id"
211
- :data-testid="`provider-reset-${row.id}`"
212
- @click="resetPatch(row)"
213
- >
214
- <Icon name="lucide:undo-2" class="mr-1.5 w-3.5 h-3.5" />Reset
215
- </UButton>
216
- <template v-if="row.editable">
217
- <UButton
218
- size="sm"
219
- variant="ghost"
220
- :aria-label="`Edit ${row.id}`"
221
- :data-testid="`provider-edit-${row.id}`"
222
- @click="openEdit(row)"
223
- >
224
- <Icon name="lucide:pencil" class="w-3.5 h-3.5" />
225
- </UButton>
226
- <UButton
227
- size="sm"
228
- variant="ghost"
229
- color="error"
230
- :aria-label="`Delete ${row.id}`"
231
- :data-testid="`provider-delete-${row.id}`"
232
- @click="requestDelete(row)"
233
- >
234
- <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
235
- </UButton>
236
- </template>
237
- </div>
238
- </td>
239
- </tr>
240
- </tbody>
241
- </table>
242
- </div>
133
+ <div class="space-y-1 max-w-md">
134
+ <USelect
135
+ v-if="row.needsCredential"
136
+ :model-value="row.credentialValue"
137
+ :items="row.credentialOptions"
138
+ placeholder="Choose a credential"
139
+ :disabled="!row.canEditCredential || rowBusy === row.id"
140
+ :data-testid="`provider-credential-${row.id}`"
141
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
142
+ @update:model-value="onCredentialSelect(row, String($event ?? ''))"
143
+ />
144
+ <p :class="row.configured ? 'text-foreground-subtle' : 'text-amber-600 dark:text-amber-400'" class="italic">
145
+ {{ row.status }}
146
+ </p>
147
+
148
+ <!-- Inline key editor -->
149
+ <div
150
+ v-if="keyEditing === row.id"
151
+ :data-testid="`provider-credential-enter-key-${row.id}`"
152
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
153
+ >
154
+ <UInput
155
+ v-model="keyForm.name"
156
+ placeholder="PROVIDER_API_KEY"
157
+ :data-testid="`provider-key-name-${row.id}`"
158
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
159
+ />
160
+ <UInput
161
+ v-model="keyForm.value"
162
+ type="password"
163
+ autocomplete="off"
164
+ placeholder="Key"
165
+ :data-testid="`provider-key-value-${row.id}`"
166
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
167
+ />
168
+ <p v-if="keyError" class="text-error-500">{{ keyError }}</p>
169
+ <div class="flex justify-end gap-1">
170
+ <UButton size="xs" variant="ghost" :data-testid="`provider-key-cancel-${row.id}`" @click="cancelKeyEditor()">Cancel</UButton>
171
+ <UButton
172
+ size="xs"
173
+ color="primary"
174
+ :loading="rowBusy === row.id"
175
+ :data-testid="`provider-key-save-${row.id}`"
176
+ @click="saveKeyEditor()"
177
+ >Save</UButton>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Inline reference editor -->
182
+ <div
183
+ v-if="refEditing === row.id"
184
+ :data-testid="`provider-credential-ref-${row.id}`"
185
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
186
+ >
187
+ <UInput
188
+ v-model="refForm.ref"
189
+ placeholder="keyring:forge/name · cmd:… · platform:…"
190
+ :data-testid="`provider-ref-input-${row.id}`"
191
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
192
+ />
193
+ <p v-if="refError" class="text-error-500">{{ refError }}</p>
194
+ <div class="flex justify-end gap-1">
195
+ <UButton size="xs" variant="ghost" :data-testid="`provider-ref-cancel-${row.id}`" @click="cancelRefEditor()">Cancel</UButton>
196
+ <UButton
197
+ size="xs"
198
+ color="primary"
199
+ :loading="rowBusy === row.id"
200
+ :data-testid="`provider-ref-save-${row.id}`"
201
+ @click="saveRefEditor()"
202
+ >Save</UButton>
203
+ </div>
204
+ </div>
205
+
206
+ <p
207
+ v-for="notice in row.overrideNotices"
208
+ :key="notice"
209
+ :data-testid="`provider-override-notice-${row.id}`"
210
+ class="text-foreground-subtle"
211
+ >{{ notice }}</p>
212
+ <p
213
+ v-if="testResults[row.id]"
214
+ :data-testid="`provider-test-result-${row.id}`"
215
+ :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
216
+ >{{ testResults[row.id]?.message }}</p>
217
+ <p
218
+ v-if="rowError?.id === row.id"
219
+ :data-testid="`provider-row-error-${row.id}`"
220
+ class="text-error-500"
221
+ >{{ rowError.message }}</p>
222
+ </div>
223
+ </li>
224
+ </ul>
243
225
 
244
226
  <!-- Add / edit dialog -->
245
227
  <UModal
@@ -392,7 +374,9 @@
392
374
  </template>
393
375
 
394
376
  <script setup lang="ts">
377
+ import { computed } from "vue";
395
378
  import type { ProvidersPanelClient } from "../vue/providers-client";
379
+ import { providersPanelNote, providersPanelRowMeta as rowMeta } from "../vue/providers-panel-model";
396
380
  import { type ProvidersPanelLayer, useProvidersPanel } from "../vue/useProvidersPanel";
397
381
 
398
382
  const props = withDefaults(
@@ -416,11 +400,15 @@ const props = withDefaults(
416
400
  layer: ProvidersPanelLayer;
417
401
  /** Disables add, edit and delete. */
418
402
  readOnly?: boolean;
403
+ /** The line above the list. Absent: the default text for `layer`; a string replaces it; `false` hides it. */
404
+ note?: string | false;
419
405
  }>(),
420
406
  // Without a default Vue casts an absent boolean to `false`; the panel tells absent from `false`.
421
- { environmentEnabled: undefined },
407
+ { environmentEnabled: undefined, note: undefined },
422
408
  );
423
409
 
410
+ const noteText = computed(() => providersPanelNote(props.note, props.layer));
411
+
424
412
  const emit = defineEmits<{
425
413
  /** After every successful `PUT`/`DELETE`. */
426
414
  (event: "changed"): void;
@@ -45,6 +45,11 @@ export interface ProvidersPanelProps {
45
45
  layer: ProvidersPanelLayer;
46
46
  /** Disables every write (non-admin at instance scope; provider lock). */
47
47
  readOnly?: boolean;
48
+ /**
49
+ * The line above the provider list. Absent: the panel's own text for
50
+ * `layer`; a string replaces it; `false` hides it (Add provider stays).
51
+ */
52
+ note?: string | false;
48
53
  }
49
54
 
50
55
  /** Emits both variants declare. */
@@ -161,6 +166,32 @@ export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
161
166
 
162
167
  export const DEFAULT_PRESETS_URL = "/api/providers/presets";
163
168
 
169
+ /** The line `ForgeProvidersPanel` shows above its list; `null` = none. */
170
+ export function providersPanelNote(
171
+ note: string | false | undefined,
172
+ layer: ProvidersPanelLayer,
173
+ ): string | null {
174
+ if (note === false) return null;
175
+ if (note !== undefined) return note;
176
+ return layer === "workspace"
177
+ ? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited. Keys stay in the environment; an entry only references them."
178
+ : "The instance registry, $FORGE_HOME/.skaile/providers.yml. Keys stay in the environment; an entry only references them.";
179
+ }
180
+
181
+ /**
182
+ * The muted line under a provider's name: its id (when the name differs), its
183
+ * preset or surface and its base URL, without repeating a value.
184
+ */
185
+ export function providersPanelRowMeta(row: {
186
+ id: string;
187
+ name: string;
188
+ origin: string;
189
+ baseUrl?: string | null;
190
+ }): string {
191
+ const parts = [row.name !== row.id ? row.id : null, row.origin, row.baseUrl ?? null];
192
+ return [...new Set(parts.filter((part): part is string => !!part))].join(" · ");
193
+ }
194
+
164
195
  /** An `env:NAME` credential reference; group 1 is the variable name. */
165
196
  export const ENV_REF_PATTERN = /^env:(.+)$/;
166
197