broapp 0.4.4 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -39,6 +39,8 @@ export interface RunEndDetail {
39
39
  /** Tool round trips the turn made. */
40
40
  readonly steps: number;
41
41
  readonly ms: number;
42
+ /** The model the turn was sent to; absent when the turn ended before one was resolved. */
43
+ readonly modelId?: string;
42
44
  }
43
45
 
44
46
  /**
@@ -165,6 +167,13 @@ export interface CreateAiOptions {
165
167
  * reason to fail it.
166
168
  */
167
169
  readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
170
+ /**
171
+ * Called after each completed model step of a turn, with the tokens its
172
+ * completed steps have used so far: what a running turn has cost, before
173
+ * `onRunEnd` says what it cost in all. A hook that throws is logged and
174
+ * ignored, like the others.
175
+ */
176
+ readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
168
177
  }
169
178
 
170
179
  /**
@@ -334,6 +343,7 @@ export function createAi(options: CreateAiOptions): Ai {
334
343
  approvals,
335
344
  ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
336
345
  ...(options.onContext === undefined ? {} : { onContext: options.onContext }),
346
+ ...(options.onUsageSoFar === undefined ? {} : { onUsageSoFar: options.onUsageSoFar }),
337
347
  transcripts: {
338
348
  save: (runId, messages) => {
339
349
  threadStore().saveTranscript(runId, messages);
@@ -75,6 +75,13 @@ export interface RunDeps {
75
75
  ) => void;
76
76
  /** Called once per turn with what the model is about to be given. */
77
77
  readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
78
+ /**
79
+ * Called after each model step completes, with what the turn's completed
80
+ * steps have used so far. Not the SDK's `onStepEnd`, which this layer
81
+ * already hands to `streamText`: this is the turn's running subtotal, for
82
+ * somebody who wants to say what a turn has cost before it ends.
83
+ */
84
+ readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
78
85
  /**
79
86
  * The turn transcripts. Absent, every history turn is text and nothing is
80
87
  * written, which is exactly the layer before transcripts existed.
@@ -95,6 +102,8 @@ interface TurnTally {
95
102
  stepsEnded: number;
96
103
  stepInput: number;
97
104
  stepOutput: number;
105
+ /** The model the turn was sent to, once it is resolved. */
106
+ modelId?: string;
98
107
  }
99
108
 
100
109
  /**
@@ -798,6 +807,7 @@ export async function runChat(
798
807
  steps: tally.steps,
799
808
  ms: Date.now() - started,
800
809
  ...(tally.usage === undefined ? {} : { usage: tally.usage }),
810
+ ...(tally.modelId === undefined ? {} : { modelId: tally.modelId }),
801
811
  };
802
812
  safely(deps.logger, 'onRunEnd', () =>
803
813
  onRunEnd(params.runId, status, params.message.slice(0, SUMMARY_CHARS), detail),
@@ -860,6 +870,9 @@ async function runTurn(
860
870
  // after the provider and key checks, so the vision check below and the model
861
871
  // instance built later both follow it without a second code path.
862
872
  const resolved = await deps.registry.resolve({ modelId: params.modelId });
873
+ // Known here and nowhere later: a listener writing down what the turn used
874
+ // is told which model used it, not left to guess from settings since changed.
875
+ tally.modelId = resolved.modelId;
863
876
 
864
877
  // Both checks come before anything is emitted, so a turn that cannot carry
865
878
  // its images fails as a whole rather than half-answering.
@@ -930,6 +943,11 @@ async function runTurn(
930
943
  tally.stepsEnded += 1;
931
944
  tally.stepInput += step.usage.inputTokens ?? 0;
932
945
  tally.stepOutput += step.usage.outputTokens ?? 0;
946
+ const onUsageSoFar = deps.onUsageSoFar;
947
+ if (onUsageSoFar !== undefined) {
948
+ const soFar = { inputTokens: tally.stepInput, outputTokens: tally.stepOutput };
949
+ safely(deps.logger, 'onUsageSoFar', () => onUsageSoFar(params.runId, soFar));
950
+ }
933
951
  },
934
952
  });
935
953
 
@@ -8,6 +8,10 @@
8
8
  * interface. And the data notice is always visible once a provider is chosen,
9
9
  * because "where do my notes go" is not a question a user should have to open
10
10
  * a menu to answer.
11
+ *
12
+ * Every control carries two classes: the application's own (`input`, `button`)
13
+ * so a host that styles those still reaches it, and an `ai-settings__` one that
14
+ * `ai.css` dresses, so the panel is whole in a host that styles neither.
11
15
  */
12
16
  import * as React from 'react';
13
17
 
@@ -17,14 +21,79 @@ import { useAiSettings, type ConnectionResult } from './use-ai-settings.ts';
17
21
  /** Shown while nothing is chosen. */
18
22
  const NOT_SET_UP = 'Not set up';
19
23
 
24
+ /*
25
+ * The icons are drawn here rather than imported: this package has no icon
26
+ * dependency, and a Broapp page may load nothing from off-origin.
27
+ */
28
+ function Icon({ children }: { children: React.ReactNode }): React.ReactElement {
29
+ return (
30
+ <svg
31
+ aria-hidden="true"
32
+ className="ai-settings__icon"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ strokeWidth="2"
38
+ viewBox="0 0 24 24"
39
+ >
40
+ {children}
41
+ </svg>
42
+ );
43
+ }
44
+
45
+ const ChevronIcon = (): React.ReactElement => (
46
+ <Icon>
47
+ <path d="m6 9 6 6 6-6" />
48
+ </Icon>
49
+ );
50
+
51
+ const KeyIcon = (): React.ReactElement => (
52
+ <Icon>
53
+ <path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
54
+ <path d="m21 2-9.6 9.6" />
55
+ <circle cx="7.5" cy="15.5" r="5.5" />
56
+ </Icon>
57
+ );
58
+
59
+ const RefreshIcon = (): React.ReactElement => (
60
+ <Icon>
61
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
62
+ <path d="M21 3v5h-5" />
63
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
64
+ <path d="M8 16H3v5" />
65
+ </Icon>
66
+ );
67
+
68
+ const InfoIcon = (): React.ReactElement => (
69
+ <Icon>
70
+ <circle cx="12" cy="12" r="10" />
71
+ <path d="M12 16v-4" />
72
+ <path d="M12 8h.01" />
73
+ </Icon>
74
+ );
75
+
76
+ /** A select with the panel's own chevron: `ai.css` may not use `url()`. */
77
+ function Select(props: React.ComponentProps<'select'>): React.ReactElement {
78
+ return (
79
+ <span className="ai-settings__select">
80
+ <select {...props} className="input input--select ai-settings__input" />
81
+ <ChevronIcon />
82
+ </span>
83
+ );
84
+ }
85
+
20
86
  export function AiSettings(): React.ReactElement {
21
87
  const { settings, providers, pending, error, update, test } = useAiSettings();
22
88
  const models = useAiModels();
23
89
  const [key, setKey] = React.useState('');
90
+ const [replacing, setReplacing] = React.useState(false);
24
91
  const [baseUrl, setBaseUrl] = React.useState<string | null>(null);
92
+ const [testing, setTesting] = React.useState(false);
25
93
  const [result, setResult] = React.useState<ConnectionResult | null>(null);
26
94
 
27
95
  const provider = providers.find((entry) => entry.id === settings?.provider) ?? null;
96
+ const hasKey = settings?.hasKey === true;
28
97
  // The input tracks the saved value until the user types, at which point
29
98
  // their draft wins until it is saved on blur.
30
99
  const urlValue = baseUrl ?? settings?.baseUrl ?? '';
@@ -33,6 +102,7 @@ export function AiSettings(): React.ReactElement {
33
102
  setResult(null);
34
103
  setBaseUrl(null);
35
104
  setKey('');
105
+ setReplacing(false);
36
106
  await update(id === '' ? { provider: undefined } : { provider: id });
37
107
  };
38
108
 
@@ -40,26 +110,36 @@ export function AiSettings(): React.ReactElement {
40
110
  if (key === '') return;
41
111
  await update({ apiKey: key });
42
112
  setKey('');
113
+ setReplacing(false);
114
+ };
115
+
116
+ const onTest = async (): Promise<void> => {
117
+ setTesting(true);
118
+ setResult(null);
119
+ setResult(await test());
120
+ setTesting(false);
43
121
  };
44
122
 
45
123
  return (
46
124
  <section className="card ai-settings" aria-labelledby="ai-settings-title">
47
- <h2 className="card__title" id="ai-settings-title">
48
- AI
49
- </h2>
125
+ <header className="ai-settings__header">
126
+ <h2 className="card__title ai-settings__title" id="ai-settings-title">
127
+ AI connection
128
+ </h2>
129
+ <p className="ai-settings__lede">Choose how your assistant connects.</p>
130
+ </header>
50
131
 
51
132
  {error !== null ? (
52
- <p className="message message--error" role="alert">
133
+ <p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
53
134
  {error.message}
54
135
  </p>
55
136
  ) : null}
56
137
 
57
- <div className="form__row">
58
- <label className="form__label" htmlFor="ai-provider">
138
+ <div className="form__row ai-settings__field">
139
+ <label className="form__label ai-settings__label" htmlFor="ai-provider">
59
140
  Provider
60
141
  </label>
61
- <select
62
- className="input input--select"
142
+ <Select
63
143
  id="ai-provider"
64
144
  disabled={pending}
65
145
  value={settings?.provider ?? ''}
@@ -71,23 +151,31 @@ export function AiSettings(): React.ReactElement {
71
151
  {entry.label}
72
152
  </option>
73
153
  ))}
74
- </select>
154
+ </Select>
75
155
  </div>
76
156
 
77
157
  {provider === null ? null : (
78
158
  <>
79
159
  {provider.needs.baseUrl === 'none' ? null : (
80
- <div className="form__row">
81
- <label className="form__label" htmlFor="ai-base-url">
82
- Server address{provider.needs.baseUrl === 'required' ? ' (required)' : ''}
83
- </label>
160
+ <div className="form__row ai-settings__field">
161
+ <div className="ai-settings__label-row">
162
+ <label className="form__label ai-settings__label" htmlFor="ai-base-url">
163
+ Server address
164
+ </label>
165
+ {provider.needs.baseUrl === 'required' ? (
166
+ <span className="ai-settings__meta" id="ai-base-url-meta">
167
+ Required
168
+ </span>
169
+ ) : null}
170
+ </div>
84
171
  <input
85
- className="input"
172
+ className="input ai-settings__input"
86
173
  id="ai-base-url"
87
174
  type="url"
88
175
  autoComplete="off"
89
176
  spellCheck={false}
90
177
  disabled={pending}
178
+ aria-describedby={provider.needs.baseUrl === 'required' ? 'ai-base-url-meta' : undefined}
91
179
  placeholder={provider.defaultBaseUrl ?? 'http://127.0.0.1:11434/v1'}
92
180
  value={urlValue}
93
181
  onChange={(event) => setBaseUrl(event.target.value)}
@@ -102,120 +190,173 @@ export function AiSettings(): React.ReactElement {
102
190
  )}
103
191
 
104
192
  {provider.needs.apiKey === 'none' ? null : (
105
- <div className="form__row">
106
- <label className="form__label" htmlFor="ai-key">
107
- API key{provider.needs.apiKey === 'optional' ? ' (optional)' : ''}
108
- </label>
109
- {provider.needs.apiKey === 'optional' ? (
110
- <p className="form__hint">
111
- Needed for a hosted service such as OpenRouter. Leave empty for a server on this
112
- computer that does not ask for one.
113
- </p>
114
- ) : null}
115
- <div className="ai-settings__key">
116
- <input
117
- className="input"
118
- id="ai-key"
119
- type="password"
120
- autoComplete="off"
121
- spellCheck={false}
122
- disabled={pending}
123
- placeholder={settings?.hasKey === true ? 'Replace the saved key' : 'Paste the key'}
124
- value={key}
125
- onChange={(event) => setKey(event.target.value)}
126
- onBlur={() => void onSaveKey()}
127
- />
128
- <button
129
- className="button button--primary"
130
- type="button"
131
- disabled={pending || key === ''}
132
- onClick={() => void onSaveKey()}
133
- >
134
- Save key
135
- </button>
193
+ <>
194
+ <div className="form__row ai-settings__field">
195
+ <div className="ai-settings__label-row">
196
+ <label className="form__label ai-settings__label" htmlFor="ai-key" id="ai-key-label">
197
+ API key
198
+ </label>
199
+ <span
200
+ className={`ai-settings__meta${hasKey ? ' ai-settings__meta--ok' : ''}`}
201
+ id="ai-key-meta"
202
+ >
203
+ {hasKey ? 'Saved' : provider.needs.apiKey === 'optional' ? 'Optional' : 'Required'}
204
+ </span>
205
+ </div>
206
+ {hasKey && !replacing ? (
207
+ // The saved key is never in the page: only its last characters.
208
+ <div className="ai-settings__saved-key" role="group" aria-labelledby="ai-key-label ai-key-meta">
209
+ <KeyIcon />
210
+ <span className="ai-settings__key-hint">
211
+ <span aria-hidden="true">•••••••••</span>
212
+ <span className="ai-settings__sr">A key ending in </span>
213
+ {settings?.keyHint ?? '…'}
214
+ </span>
215
+ <button
216
+ className="ai-settings__inline-action"
217
+ type="button"
218
+ disabled={pending}
219
+ onClick={() => setReplacing(true)}
220
+ >
221
+ Replace
222
+ </button>
223
+ <button
224
+ className="ai-settings__inline-action"
225
+ type="button"
226
+ disabled={pending}
227
+ onClick={() => void update({ apiKey: null })}
228
+ >
229
+ Remove
230
+ </button>
231
+ </div>
232
+ ) : (
233
+ <div className="ai-settings__key">
234
+ <input
235
+ className="input ai-settings__input"
236
+ id="ai-key"
237
+ type="password"
238
+ autoComplete="off"
239
+ spellCheck={false}
240
+ // Replace was just pressed: the field is why.
241
+ autoFocus={replacing}
242
+ disabled={pending}
243
+ aria-describedby="ai-key-meta"
244
+ placeholder={hasKey ? 'Paste the new key' : 'Paste the key'}
245
+ value={key}
246
+ onChange={(event) => setKey(event.target.value)}
247
+ onBlur={() => void onSaveKey()}
248
+ />
249
+ <button
250
+ className="button button--primary ai-settings__button ai-settings__button--primary"
251
+ type="button"
252
+ disabled={pending || key === ''}
253
+ onClick={() => void onSaveKey()}
254
+ >
255
+ Save
256
+ </button>
257
+ {replacing ? (
258
+ <button
259
+ className="button ai-settings__button"
260
+ type="button"
261
+ // Keeps the field's blur — which saves — from running first.
262
+ onMouseDown={(event) => event.preventDefault()}
263
+ onClick={() => {
264
+ setKey('');
265
+ setReplacing(false);
266
+ }}
267
+ >
268
+ Cancel
269
+ </button>
270
+ ) : null}
271
+ </div>
272
+ )}
273
+ {provider.needs.apiKey === 'optional' ? (
274
+ <p className="form__hint ai-settings__hint">
275
+ Required for hosted services. Optional for local servers.
276
+ </p>
277
+ ) : null}
136
278
  </div>
137
- {settings?.hasKey === true ? (
138
- <p className="form__hint">
139
- A key ending in {settings.keyHint ?? '…'} is saved.{' '}
140
- <button
141
- className="button"
142
- type="button"
279
+
280
+ <div className="form__row ai-settings__field">
281
+ <label className="ai-settings__switch-row" htmlFor="ai-remember">
282
+ <span className="ai-settings__label">Remember key on this computer</span>
283
+ <input
284
+ className="ai-settings__switch"
285
+ id="ai-remember"
286
+ type="checkbox"
287
+ role="switch"
143
288
  disabled={pending}
144
- onClick={() => void update({ apiKey: null })}
145
- >
146
- Remove key
147
- </button>
289
+ aria-describedby="ai-remember-hint"
290
+ checked={settings?.remember ?? true}
291
+ onChange={(event) => void update({ remember: event.target.checked })}
292
+ />
293
+ </label>
294
+ <p className="form__hint ai-settings__hint" id="ai-remember-hint">
295
+ Saved in the app&rsquo;s data folder, accessible to your account. Turn off to keep
296
+ the key only until the app closes.
148
297
  </p>
149
- ) : null}
150
- <label className="form__label ai-settings__remember" htmlFor="ai-remember">
151
- <input
152
- id="ai-remember"
153
- type="checkbox"
154
- disabled={pending}
155
- checked={settings?.remember ?? true}
156
- onChange={(event) => void update({ remember: event.target.checked })}
157
- />
158
- Remember key on this computer
159
- </label>
160
- <p className="form__hint">
161
- Stored in this application&rsquo;s data folder, readable by your user account. Turn
162
- off to keep it only until the app closes.
163
- </p>
164
- </div>
298
+ </div>
299
+ </>
165
300
  )}
166
301
 
167
- <div className="form__row">
168
- <label className="form__label" htmlFor="ai-model">
169
- Model
170
- </label>
171
- <div className="ai-settings__key">
172
- <select
173
- className="input input--select"
174
- id="ai-model"
175
- disabled={pending || models.pending}
176
- value={settings?.modelId ?? ''}
177
- onChange={(event) => void update({ modelId: event.target.value })}
178
- >
179
- <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
180
- {models.models.map((model) => (
181
- <option key={model.modelId} value={model.modelId}>
182
- {model.label}
183
- </option>
184
- ))}
185
- </select>
302
+ <div className="form__row ai-settings__field">
303
+ <div className="ai-settings__label-row">
304
+ <label className="form__label ai-settings__label" htmlFor="ai-model">
305
+ Model
306
+ </label>
186
307
  <button
187
- className="button"
308
+ className="ai-settings__inline-action ai-settings__inline-action--icon"
188
309
  type="button"
189
310
  disabled={models.pending}
190
311
  onClick={() => void models.refresh()}
191
312
  >
313
+ <RefreshIcon />
192
314
  Refresh
193
315
  </button>
194
316
  </div>
317
+ <Select
318
+ id="ai-model"
319
+ disabled={pending || models.pending}
320
+ value={settings?.modelId ?? ''}
321
+ onChange={(event) => void update({ modelId: event.target.value })}
322
+ >
323
+ <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
324
+ {models.models.map((model) => (
325
+ <option key={model.modelId} value={model.modelId}>
326
+ {model.label}
327
+ </option>
328
+ ))}
329
+ </Select>
195
330
  {models.error === null ? null : (
196
- <p className="message message--error" role="alert">
331
+ <p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
197
332
  {models.error.message}
198
333
  </p>
199
334
  )}
200
335
  </div>
201
336
 
202
337
  <p className="ai-settings__notice" role="status">
203
- {provider.local
204
- ? 'Runs on this computer. Nothing is sent over the internet.'
205
- : `Messages, the documents you are viewing, and search results are sent to ${provider.label} to generate answers.`}
338
+ <InfoIcon />
339
+ <span>
340
+ {provider.local
341
+ ? 'Runs on this computer. Nothing is sent over the internet.'
342
+ : `Messages, open documents and search results are sent to ${provider.label} to generate answers.`}
343
+ </span>
206
344
  </p>
207
345
 
208
- <div className="form__row">
346
+ <div className="form__row ai-settings__field">
209
347
  <button
210
- className="button button--primary"
348
+ className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
211
349
  type="button"
212
350
  disabled={pending}
213
- onClick={() => void test().then(setResult)}
351
+ onClick={() => void onTest()}
214
352
  >
215
- Test connection
353
+ {testing ? 'Testing…' : 'Test connection'}
216
354
  </button>
217
355
  {result === null ? null : (
218
- <p className={`message ${result.ok ? 'message--ok' : 'message--error'}`} role="status">
356
+ <p
357
+ className={`message ${result.ok ? 'message--ok' : 'message--error'} ai-settings__message ai-settings__message--${result.ok ? 'ok' : 'error'}`}
358
+ role="status"
359
+ >
219
360
  {result.message}
220
361
  {result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
221
362
  </p>
@@ -9,23 +9,332 @@
9
9
  * components are still legible in an application that defines none.
10
10
  */
11
11
 
12
+ /*
13
+ * The settings panel.
14
+ *
15
+ * One height, one radius and one border for every control, so a select, a
16
+ * field, the saved-key row and a button beside them sit on one line and read
17
+ * as one set. The frame — a card, or none — is the application's to draw.
18
+ *
19
+ * The lengths follow Autoapp's control tokens where an application has them,
20
+ * so this panel and a form the renderer draws on the same page are one set of
21
+ * controls; each falls back to that token's default, so an application with no
22
+ * renderer gets the same panel. An `--ai-*` property set on `.ai-settings` or
23
+ * above it beats both.
24
+ */
25
+ .ai-settings {
26
+ --ai-control-height: 2.5rem;
27
+ --ai-control-radius: var(--autoapp-radius-sm, 7px);
28
+ --ai-border-width: var(--autoapp-border-width, 1px);
29
+ --ai-label-size: var(--autoapp-font-size-small, 0.85rem);
30
+ --ai-label-weight: var(--autoapp-weight-strong, 600);
31
+ --ai-control-size: var(--autoapp-font-size-note, 0.9rem);
32
+ --ai-hint-size: var(--autoapp-font-size-caption, 0.8rem);
33
+ --ai-focus-ring: var(--autoapp-focus-ring, var(--accent, #1f5f4f));
34
+ --ai-focus-ring-width: var(--autoapp-focus-ring-width, 2px);
35
+ --ai-focus-ring-offset: var(--autoapp-focus-ring-offset, 1px);
36
+
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 1.1rem;
40
+ }
41
+
42
+ .ai-settings__header {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 0.2rem;
46
+ }
47
+
48
+ .ai-settings__title {
49
+ margin: 0;
50
+ }
51
+
52
+ .ai-settings__lede {
53
+ margin: 0;
54
+ color: var(--text-muted, #6b6862);
55
+ }
56
+
57
+ .ai-settings__field {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 0.4rem;
61
+ margin: 0;
62
+ }
63
+
64
+ .ai-settings__label-row {
65
+ display: flex;
66
+ gap: 0.75rem;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ }
70
+
71
+ .ai-settings__label {
72
+ margin: 0;
73
+ font-size: var(--ai-label-size);
74
+ font-weight: var(--ai-label-weight);
75
+ color: var(--text, #1b1a18);
76
+ }
77
+
78
+ .ai-settings__meta {
79
+ font-size: var(--ai-hint-size);
80
+ color: var(--text-muted, #6b6862);
81
+ }
82
+
83
+ .ai-settings__meta--ok {
84
+ color: var(--ok, #1f5f4f);
85
+ }
86
+
87
+ .ai-settings__hint {
88
+ margin: 0;
89
+ font-size: var(--ai-hint-size);
90
+ line-height: 1.45;
91
+ color: var(--text-muted, #6b6862);
92
+ }
93
+
94
+ .ai-settings__icon {
95
+ flex: none;
96
+ width: 1rem;
97
+ height: 1rem;
98
+ }
99
+
100
+ .ai-settings__input,
101
+ .ai-settings__saved-key {
102
+ width: 100%;
103
+ min-width: 0;
104
+ height: var(--ai-control-height);
105
+ padding: 0 0.75rem;
106
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
107
+ border-radius: var(--ai-control-radius);
108
+ background: var(--surface, #ffffff);
109
+ font: inherit;
110
+ font-size: var(--ai-control-size);
111
+ color: var(--text, #1b1a18);
112
+ }
113
+
114
+ .ai-settings__input::placeholder {
115
+ color: var(--text-muted, #6b6862);
116
+ opacity: 0.8;
117
+ }
118
+
119
+ .ai-settings__input:hover:not(:disabled) {
120
+ border-color: var(--text-muted, #6b6862);
121
+ }
122
+
123
+ .ai-settings__input:focus-visible,
124
+ .ai-settings__button:focus-visible,
125
+ .ai-settings__inline-action:focus-visible,
126
+ .ai-settings__switch:focus-visible {
127
+ outline: var(--ai-focus-ring-width) solid var(--ai-focus-ring);
128
+ outline-offset: var(--ai-focus-ring-offset);
129
+ }
130
+
131
+ .ai-settings__input:disabled {
132
+ opacity: 0.6;
133
+ }
134
+
135
+ .ai-settings__select {
136
+ position: relative;
137
+ display: block;
138
+ }
139
+
140
+ .ai-settings__select select {
141
+ appearance: none;
142
+ padding-right: 2.25rem;
143
+ text-overflow: ellipsis;
144
+ }
145
+
146
+ .ai-settings__select .ai-settings__icon {
147
+ position: absolute;
148
+ top: 50%;
149
+ right: 0.75rem;
150
+ translate: 0 -50%;
151
+ color: var(--text-muted, #6b6862);
152
+ pointer-events: none;
153
+ }
154
+
12
155
  .ai-settings__key {
13
156
  display: flex;
14
157
  gap: 0.5rem;
15
158
  align-items: center;
16
159
  }
17
160
 
18
- .ai-settings__key .input {
161
+ /* The saved key: what a field looks like when there is nothing to type. */
162
+ .ai-settings__saved-key {
163
+ display: flex;
164
+ gap: 0.6rem;
165
+ align-items: center;
166
+ padding-right: 0.25rem;
167
+ }
168
+
169
+ .ai-settings__saved-key .ai-settings__icon {
170
+ color: var(--text-muted, #6b6862);
171
+ }
172
+
173
+ .ai-settings__key-hint {
19
174
  flex: 1 1 auto;
20
175
  min-width: 0;
176
+ overflow: hidden;
177
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
178
+ font-size: 0.85rem;
179
+ letter-spacing: 0.04em;
180
+ white-space: nowrap;
181
+ }
182
+
183
+ .ai-settings__sr {
184
+ position: absolute;
185
+ width: 1px;
186
+ height: 1px;
187
+ overflow: hidden;
188
+ clip-path: inset(50%);
189
+ white-space: nowrap;
190
+ }
191
+
192
+ /* A quiet action inside a row: Replace, Remove, Refresh. */
193
+ .ai-settings__inline-action {
194
+ display: inline-flex;
195
+ flex: none;
196
+ gap: 0.35rem;
197
+ align-items: center;
198
+ padding: 0.3rem 0.55rem;
199
+ border: 0;
200
+ border-radius: 6px;
201
+ background: transparent;
202
+ font: inherit;
203
+ font-size: 0.85rem;
204
+ font-weight: 500;
205
+ color: var(--text-muted, #6b6862);
206
+ cursor: pointer;
207
+ }
208
+
209
+ .ai-settings__inline-action:hover:not(:disabled) {
210
+ background: var(--bg, #fbfbfa);
211
+ color: var(--text, #1b1a18);
212
+ }
213
+
214
+ .ai-settings__inline-action:disabled {
215
+ opacity: 0.5;
216
+ cursor: default;
217
+ }
218
+
219
+ .ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action {
220
+ position: relative;
221
+ margin-left: 0.3rem;
222
+ }
223
+
224
+ .ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action::before {
225
+ position: absolute;
226
+ top: 20%;
227
+ bottom: 20%;
228
+ left: -0.2rem;
229
+ width: 1px;
230
+ background: var(--border, #e3e2df);
231
+ content: '';
232
+ }
233
+
234
+ /* Pulled up so the label row is no taller than one without a button. */
235
+ .ai-settings__inline-action--icon {
236
+ margin: -0.3rem -0.55rem -0.3rem 0;
237
+ }
238
+
239
+ .ai-settings__button {
240
+ flex: none;
241
+ height: var(--ai-control-height);
242
+ padding: 0 1rem;
243
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
244
+ border-radius: var(--ai-control-radius);
245
+ background: var(--surface, #ffffff);
246
+ font: inherit;
247
+ font-size: var(--ai-control-size);
248
+ font-weight: var(--ai-label-weight);
249
+ color: var(--text, #1b1a18);
250
+ cursor: pointer;
251
+ transition: filter 150ms ease-out;
252
+ }
253
+
254
+ .ai-settings__button--primary {
255
+ border-color: transparent;
256
+ background: var(--accent, #1f5f4f);
257
+ color: var(--accent-contrast, #ffffff);
258
+ }
259
+
260
+ .ai-settings__button--block {
261
+ width: 100%;
262
+ }
263
+
264
+ .ai-settings__button:hover:not(:disabled) {
265
+ filter: brightness(1.08);
266
+ }
267
+
268
+ .ai-settings__button:active:not(:disabled) {
269
+ filter: brightness(0.95);
21
270
  }
22
271
 
23
- .ai-settings__remember {
272
+ .ai-settings__button:disabled {
273
+ opacity: 0.5;
274
+ cursor: default;
275
+ }
276
+
277
+ .ai-settings__switch-row {
24
278
  display: flex;
25
- gap: 0.5rem;
279
+ gap: 1rem;
26
280
  align-items: center;
27
- margin-top: 0.75rem;
28
- font-weight: 400;
281
+ justify-content: space-between;
282
+ cursor: pointer;
283
+ }
284
+
285
+ /* A checkbox, drawn as the switch it is announced as. */
286
+ .ai-settings__switch {
287
+ position: relative;
288
+ flex: none;
289
+ width: 2.25rem;
290
+ height: 1.3rem;
291
+ margin: 0;
292
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
293
+ border-radius: 999px;
294
+ background: var(--bg, #fbfbfa);
295
+ appearance: none;
296
+ cursor: pointer;
297
+ transition:
298
+ background-color 150ms ease-out,
299
+ border-color 150ms ease-out;
300
+ }
301
+
302
+ .ai-settings__switch::after {
303
+ position: absolute;
304
+ top: 50%;
305
+ left: 0.15rem;
306
+ width: 0.9rem;
307
+ height: 0.9rem;
308
+ border-radius: 50%;
309
+ background: var(--text-muted, #6b6862);
310
+ content: '';
311
+ translate: 0 -50%;
312
+ transition:
313
+ translate 150ms cubic-bezier(0.25, 1, 0.5, 1),
314
+ background-color 150ms ease-out;
315
+ }
316
+
317
+ .ai-settings__switch:checked {
318
+ border-color: transparent;
319
+ background: var(--accent, #1f5f4f);
320
+ }
321
+
322
+ .ai-settings__switch:checked::after {
323
+ background: var(--accent-contrast, #ffffff);
324
+ translate: 0.95rem -50%;
325
+ }
326
+
327
+ .ai-settings__switch:disabled {
328
+ opacity: 0.5;
329
+ cursor: default;
330
+ }
331
+
332
+ @media (prefers-reduced-motion: reduce) {
333
+ .ai-settings__switch,
334
+ .ai-settings__switch::after,
335
+ .ai-settings__button {
336
+ transition: none;
337
+ }
29
338
  }
30
339
 
31
340
  /*
@@ -33,12 +342,31 @@
33
342
  * question a user should have to open a menu to answer.
34
343
  */
35
344
  .ai-settings__notice {
36
- margin: 0.75rem 0;
37
- padding: 0.6rem 0.75rem;
38
- border: 1px solid var(--border, #e3e2df);
39
- border-radius: var(--radius, 10px);
345
+ display: flex;
346
+ gap: 0.6rem;
347
+ align-items: flex-start;
348
+ margin: 0;
40
349
  color: var(--text-muted, #6b6862);
41
- font-size: 0.9rem;
350
+ font-size: 0.85rem;
351
+ line-height: 1.45;
352
+ }
353
+
354
+ .ai-settings__notice .ai-settings__icon {
355
+ margin-top: 0.15rem;
356
+ }
357
+
358
+ .ai-settings__message {
359
+ margin: 0;
360
+ font-size: 0.85rem;
361
+ line-height: 1.45;
362
+ }
363
+
364
+ .ai-settings__message--error {
365
+ color: var(--bad, #96311f);
366
+ }
367
+
368
+ .ai-settings__message--ok {
369
+ color: var(--ok, #1f5f4f);
42
370
  }
43
371
 
44
372
  .ai-chat__messages {