dsh-plugin-subscription-oauth 0.8.14 → 0.8.15

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.
@@ -86,16 +86,17 @@ export class ProviderController {
86
86
  }
87
87
  /** Current state of the route. */
88
88
  async status() {
89
- // Keep the access token current while a user is looking at the card, so the
90
- // expiry it shows is the live one rather than a stamp that lapsed minutes
91
- // after login. Refreshing is de-duplicated per account by the token manager,
92
- // and a failure must never make `status` itself fail.
93
- await this.refreshIfLapsing();
89
+ // A status read must never trigger a network request: the settings page polls it,
90
+ // and a refresh here would mean every poll is a token call. The token is refreshed
91
+ // lazily when a request actually needs it (or by discover/usage below).
94
92
  const accounts = await this.options.store.list();
95
93
  const defaultAccount = await this.options.store.defaultAccount();
96
94
  const pending = this.options.flow.pending();
97
95
  const keys = Object.keys(accounts);
98
96
  const spec = this.options.spec;
97
+ // A stored token within its life is reported as fresh; a lapsed one reads as expired
98
+ // until a real request refreshes it. This is honest (the stored expiry is what we
99
+ // know) and avoids a network call on every status poll.
99
100
  return {
100
101
  provider: spec.id,
101
102
  displayName: spec.displayName,
@@ -147,7 +148,7 @@ export class ProviderController {
147
148
  }
148
149
  catch (error) {
149
150
  this.lastRefreshError = error;
150
- if (isReauthRequired(error)) {
151
+ if (isReauthRequired(error) || isDeadGrant(messageOf(error))) {
151
152
  this.lastError = `${this.displayName} sign-in has lapsed; connect again`;
152
153
  }
153
154
  }
@@ -226,6 +227,8 @@ export class ProviderController {
226
227
  await this.options.store.save(account ?? accountKeyOf(settled), settled);
227
228
  this.lastError = undefined;
228
229
  // A fresh login can reveal models this build has not seen yet.
230
+ // Discovery uses the stored token directly (no resolve), so it never opens
231
+ // the browser from here.
229
232
  await this.discover(true);
230
233
  }
231
234
  catch (error) {
@@ -297,21 +300,22 @@ export class ProviderController {
297
300
  // every startup would be noise.
298
301
  if (!await this.options.store.hasAny())
299
302
  return this.status();
300
- // The catalog is the union of every signed-in account's live list, because one
301
- // model id draws on the whole pool: a model any account can serve must appear, and
302
- // which account answers at call time is the scheduler's choice. A model only the
303
- // non-default account has is offered, then may fall through to it on dispatch.
303
+ // Discovery resolves tokens to read the model list, but a resolve must never
304
+ // open the browser: a transient network failure, or a token the endpoint refuses
305
+ // while still being within its stored expiry, is not a signal to start a login.
306
+ // Only an explicit user action (the connect button) opens the consent page.
304
307
  const discovered = [];
305
308
  for (const account of Object.keys(await this.options.store.list())) {
306
- let accessToken;
307
- try {
308
- accessToken = (await this.options.tokens.resolve(account)).accessToken;
309
- }
310
- catch (error) {
311
- this.options.onWarn?.(`${spec.id} model discovery for ${account} skipped: ${messageOf(error)}`);
309
+ let session = await this.options.store.get(account);
310
+ if (session === undefined)
311
+ continue;
312
+ // A token that is still within its stored expiry is used as-is; a lapsed one
313
+ // is a silent skip (the route works, the catalog is just not refreshed).
314
+ if (session.expiresAt <= Date.now()) {
315
+ this.options.onWarn?.(`${spec.id} model discovery for ${account} skipped: token expired`);
312
316
  continue;
313
317
  }
314
- const ids = await spec.discover(accessToken);
318
+ const ids = await spec.discover(session.accessToken);
315
319
  if (ids !== undefined) {
316
320
  for (const id of ids)
317
321
  if (!discovered.includes(id))
@@ -55,7 +55,7 @@ export class TokenManager {
55
55
  : `${this.options.displayName} account ${account} is not signed in`, REAUTH_REQUIRED);
56
56
  }
57
57
  const { account: key, session } = found;
58
- if (!force && Date.now() + this.preemptMs < session.expiresAt) {
58
+ if (!force && Date.now() < session.expiresAt) {
59
59
  return { account: key, accessToken: session.accessToken, expiresAt: session.expiresAt };
60
60
  }
61
61
  const refreshed = await this.refresh(key, session);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The composer's quick-mode button.
3
+ *
4
+ * The route's own fast/lightspeed variant is not a row in the model picker: it shares its
5
+ * base model's visibility and is reached from here. The composer renders
6
+ * `conversation.input.right` immediately before its model seat, so this button sits at
7
+ * the model selector's left — the control a reader looks for when they want the same
8
+ * model, faster.
9
+ *
10
+ * Off, the selection is the base model and pressing the button selects the fast variant.
11
+ * On, the selection *is* the fast variant, so the button reports active and pressing it
12
+ * returns to the base — which is why `model-variants` answers from both sides of the pair
13
+ * rather than only from the base id.
14
+ *
15
+ * The button renders nothing when the model has no fast variant, when the route is not
16
+ * one this plugin serves, or when the host exposes no way to change the selection: an
17
+ * absent control is honest, a disabled one is noise.
18
+ *
19
+ * @module dsh-plugin-subscription-oauth/client/FastModeButton
20
+ */
21
+ import type { ReactNode } from 'react';
22
+ import type { SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots';
23
+ import type { SubscriptionsCall } from './subscriptions-rpc.js';
24
+ /** Injected props the button is registered with. */
25
+ export interface FastModeButtonInjected {
26
+ /** The plugin's own RPC caller, for the one `model-variants` read. */
27
+ call: SubscriptionsCall;
28
+ /** Whether the provider is one this plugin serves. */
29
+ isOwnProvider: (provider: string) => boolean;
30
+ /**
31
+ * The session's model directory, when the host exposes one.
32
+ *
33
+ * Written when the button switches between a model and its fast variant. Absent on a
34
+ * host without it, in which case the button renders nothing.
35
+ */
36
+ modelDirectory?: {
37
+ /** Select one model; resolves when the picker has accepted it. */
38
+ select?: (selection: {
39
+ provider: string;
40
+ model: string;
41
+ }) => Promise<unknown>;
42
+ } | undefined;
43
+ }
44
+ /**
45
+ * The quick-mode control, for the composer's row of standard controls.
46
+ *
47
+ * @param props - injected RPC caller, directory, and the session standard kit.
48
+ * @returns the button, or null when this model has no fast variant.
49
+ */
50
+ export declare function FastModeButton({ call, isOwnProvider, modelDirectory, useProjection }: FastModeButtonInjected & SessionStandardProps): ReactNode;
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The composer's quick-mode button.
4
+ *
5
+ * The route's own fast/lightspeed variant is not a row in the model picker: it shares its
6
+ * base model's visibility and is reached from here. The composer renders
7
+ * `conversation.input.right` immediately before its model seat, so this button sits at
8
+ * the model selector's left — the control a reader looks for when they want the same
9
+ * model, faster.
10
+ *
11
+ * Off, the selection is the base model and pressing the button selects the fast variant.
12
+ * On, the selection *is* the fast variant, so the button reports active and pressing it
13
+ * returns to the base — which is why `model-variants` answers from both sides of the pair
14
+ * rather than only from the base id.
15
+ *
16
+ * The button renders nothing when the model has no fast variant, when the route is not
17
+ * one this plugin serves, or when the host exposes no way to change the selection: an
18
+ * absent control is honest, a disabled one is noise.
19
+ *
20
+ * @module dsh-plugin-subscription-oauth/client/FastModeButton
21
+ */
22
+ import { useEffect, useState } from 'react';
23
+ import { providerOf } from './UsageChip.js';
24
+ import { fastModeCss } from './styles.js';
25
+ /**
26
+ * The quick-mode control, for the composer's row of standard controls.
27
+ *
28
+ * @param props - injected RPC caller, directory, and the session standard kit.
29
+ * @returns the button, or null when this model has no fast variant.
30
+ */
31
+ export function FastModeButton({ call, isOwnProvider, modelDirectory, useProjection }) {
32
+ const [pair, setPair] = useState(undefined);
33
+ // A switch that the host refuses must not look like a dead button: the reason is kept
34
+ // and shown, because the only alternative is a control that silently does nothing.
35
+ const [failure, setFailure] = useState(undefined);
36
+ const [pending, setPending] = useState(false);
37
+ const selection = useProjection('modelSelection', (value) => value);
38
+ const provider = providerOf(selection);
39
+ const model = selection?.next?.model ?? selection?.lastUsed?.model;
40
+ useEffect(() => {
41
+ if (provider === undefined || model === undefined || !isOwnProvider(provider)) {
42
+ setPair(undefined);
43
+ return;
44
+ }
45
+ let live = true;
46
+ void call('model-variants', { provider, id: model })
47
+ .then((result) => {
48
+ if (!live)
49
+ return;
50
+ setPair(result?.fast === undefined
51
+ ? undefined
52
+ : { wireId: result.fast, ...result.fastBase === undefined ? {} : { baseId: result.fastBase } });
53
+ })
54
+ .catch(() => { if (live)
55
+ setPair(undefined); });
56
+ return () => { live = false; };
57
+ }, [provider, model, isOwnProvider, call]);
58
+ // A switch that landed clears the reason, so the marker follows the truth on screen.
59
+ useEffect(() => { setFailure(undefined); }, [model]);
60
+ if (pair === undefined || provider === undefined || modelDirectory?.select === undefined)
61
+ return null;
62
+ const active = pair.wireId === model;
63
+ const target = active ? pair.baseId : pair.wireId;
64
+ // Active without a base to return to would be a control that cannot switch anything off.
65
+ if (target === undefined || target === model)
66
+ return null;
67
+ const base = active
68
+ ? 'Fast mode is on; press to go back to the standard model'
69
+ : 'Fast mode: switch to the fast variant of this model';
70
+ const title = failure === undefined
71
+ ? base
72
+ : `${base}\nThe switch was refused — ${`${failure.code ?? ''} ${failure.message}`.trim()}`;
73
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: fastModeCss }), _jsxs("button", { type: "button", className: "dsh-subscription-fast-mode", "data-active": active, "data-pending": pending, "data-failed": failure !== undefined, "aria-pressed": active, "aria-busy": pending, title: title, "aria-label": title, onClick: (event) => {
74
+ event.stopPropagation();
75
+ setPending(true);
76
+ setFailure(undefined);
77
+ void Promise.resolve(modelDirectory.select?.({ provider, model: target }))
78
+ .then((result) => {
79
+ // The directory reports its own outcome: a refused selection carries why.
80
+ const outcome = result;
81
+ if (outcome?.ok !== false)
82
+ return;
83
+ const code = outcome.error?.code;
84
+ const message = outcome.error?.message ?? code ?? 'the host refused the selection';
85
+ setFailure({ ...code === undefined ? {} : { code }, message });
86
+ console.warn(`[subscription-oauth] fast mode ${provider}/${target} refused:`, outcome.error);
87
+ })
88
+ .catch((error) => {
89
+ setFailure({ message: error instanceof Error ? error.message : String(error) });
90
+ console.warn(`[subscription-oauth] fast mode ${provider}/${target} failed:`, error);
91
+ })
92
+ .finally(() => { setPending(false); });
93
+ }, children: [_jsx("svg", { className: "dsh-subscription-fast-mode-bolt", viewBox: "0 0 24 24", width: "16", height: "16", "aria-hidden": "true", fill: "none", children: _jsx("path", { d: "M13 2 3.6 13.2a1.2 1.2 0 0 0 .92 1.98H11l-.86 6.42a1.2 1.2 0 0 0 2.2.7L21.4 10.6a1.2 1.2 0 0 0-.92-1.98H13l.86-6.42A1.2 1.2 0 0 0 13 2z", fill: "currentColor", stroke: "currentColor", strokeWidth: "1.2", strokeLinejoin: "round" }) }), failure !== undefined && (_jsx("span", { className: "dsh-subscription-fast-mode-reason", children: failure.code ?? failure.message }))] })] }));
94
+ }
@@ -299,7 +299,8 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
299
299
  const lastOwn = useRef(undefined);
300
300
  // A projection read, so the chip follows the picker: changing the model re-renders it
301
301
  // with the new provider instead of waiting for the next unrelated render.
302
- const selected = useProjection('modelSelection', (value) => providerOf(value));
302
+ const selection = useProjection('modelSelection', (value) => value);
303
+ const selected = providerOf(selection);
303
304
  // A non-subscription provider has no usage windows to meter, so the chip shows its
304
305
  // balance instead, read by cc-switch's query. The host's account (deepseek-account)
305
306
  // has its own balance source and takes that over cc-switch; a subscription this plugin
@@ -11,6 +11,7 @@
11
11
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
12
12
  import type { SubscriptionsKey } from './locales.js';
13
13
  export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './SubscriptionsSection.js';
14
+ export type { FastModeButtonInjected } from './FastModeButton.js';
14
15
  export type { UsageChipInjected } from './UsageChip.js';
15
16
  export type { SubscriptionsKey } from './locales.js';
16
17
  export { createSubscriptionsCall, SubscriptionsRpcError } from './subscriptions-rpc.js';
@@ -33,6 +34,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
34
  kind: 'list';
34
35
  scope: 'session';
35
36
  };
37
+ /**
38
+ * The composer's row of compact controls, rendered immediately before its model seat.
39
+ *
40
+ * The shell declares it as a session-scoped list; this plugin's quick-mode button is
41
+ * one entry, which is what puts it at the model selector's left.
42
+ */
43
+ 'conversation.input.right': {
44
+ kind: 'list';
45
+ scope: 'session';
46
+ };
36
47
  }
37
48
  /**
38
49
  * The session standard kit, of which this plugin reads one member.
@@ -49,7 +60,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
49
60
  }
50
61
  /**
51
62
  * Required services: `slots` carries the registration seat, `connection` the
52
- * RPC caller, and `locale` the copy dictionaries.
63
+ * RPC caller, `locale` the copy dictionaries, and `modelDirectories` the picker's
64
+ * per-session model directory (written when the quick-mode button routes to a fast
65
+ * variant).
53
66
  */
54
67
  export declare const inject: string[];
55
68
  /**
@@ -9,6 +9,7 @@
9
9
  * @module dsh-plugin-subscription-oauth/client
10
10
  */
11
11
  import { SubscriptionsSection } from './SubscriptionsSection.js';
12
+ import { FastModeButton } from './FastModeButton.js';
12
13
  import { UsageChip } from './UsageChip.js';
13
14
  import { createSubscriptionsCall, createAccountBalanceCall, createCcSwitchBalanceCall } from './subscriptions-rpc.js';
14
15
  import { en, zh } from './locales.js';
@@ -31,11 +32,18 @@ const SECTION_ORDER = 92;
31
32
  * chip follows them rather than displacing them.
32
33
  */
33
34
  const CHIP_ORDER = 10;
35
+ /**
36
+ * Composer-control position: ahead of the shell's own entries in that row, if it ever
37
+ * adds any, so the quick-mode button stays adjacent to the model selector it belongs to.
38
+ */
39
+ const FAST_MODE_ORDER = 10;
34
40
  /**
35
41
  * Required services: `slots` carries the registration seat, `connection` the
36
- * RPC caller, and `locale` the copy dictionaries.
42
+ * RPC caller, `locale` the copy dictionaries, and `modelDirectories` the picker's
43
+ * per-session model directory (written when the quick-mode button routes to a fast
44
+ * variant).
37
45
  */
38
- export const inject = ['slots', 'connection', 'locale'];
46
+ export const inject = ['slots', 'connection', 'locale', 'modelDirectories'];
39
47
  /**
40
48
  * Register the Subscriptions settings section.
41
49
  *
@@ -51,6 +59,9 @@ export function apply(ctx) {
51
59
  const connection = ctx.get('connection');
52
60
  const t = ctx.locale.bind(NS);
53
61
  const call = createSubscriptionsCall(connection.rpc);
62
+ // Read once: the resolver is a service, and the per-session directory is resolved at
63
+ // registration time (see `modelDirectoryFor`).
64
+ const modelDirectories = ctx.get('modelDirectories');
54
65
  ctx.slots.inject('settings.section', () => ctx.slots.register({
55
66
  name: 'settings.section',
56
67
  id: 'subscriptions',
@@ -75,4 +86,37 @@ export function apply(ctx) {
75
86
  ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc),
76
87
  }),
77
88
  }, UsageChip));
89
+ // The composer renders this seat immediately before its model selector, so the
90
+ // quick-mode button lands at the picker's left. It renders nothing unless the current
91
+ // model really has a fast variant, so a route without one shows no control at all.
92
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
93
+ name: 'conversation.input.right',
94
+ id: 'subscription-fast-mode',
95
+ order: FAST_MODE_ORDER,
96
+ inject: (sessionId) => ({
97
+ call,
98
+ isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
99
+ modelDirectory: modelDirectoryFor(modelDirectories, sessionId),
100
+ }),
101
+ }, FastModeButton));
102
+ }
103
+ /**
104
+ * The session's model directory, in the shape the quick-mode button writes through.
105
+ *
106
+ * Absent when the host exposes no directory for the session, or exposes one that cannot
107
+ * select — in which case the button renders nothing rather than a dead control.
108
+ *
109
+ * @param modelDirectories - the host's directory resolver, when it has one.
110
+ * @param sessionId - the session whose directory is wanted.
111
+ * @returns the reader/writer pair, or undefined.
112
+ */
113
+ function modelDirectoryFor(modelDirectories, sessionId) {
114
+ if (sessionId === undefined || modelDirectories?.directoryFor === undefined)
115
+ return undefined;
116
+ const directory = modelDirectories.directoryFor(sessionId);
117
+ if (directory?.select === undefined)
118
+ return undefined;
119
+ return {
120
+ select: (selection) => directory.select?.(selection) ?? Promise.resolve(),
121
+ };
78
122
  }
@@ -30,3 +30,11 @@ export declare const subscriptionsCss = "\n.dsh-subscriptions-settings,\n.dsh-su
30
30
  * define it.
31
31
  */
32
32
  export declare const usageChipCss = "\n.dsh-subscription-usage-chip {\n position: relative;\n display: inline-flex;\n align-items: center;\n /* The bar is a centred flex row whose children are this slot's entries followed by the\n shell's context meter, and a list slot renders its entries as bare children (no\n wrapper). So the chip is itself a flex item: a higher order places it after the\n context meter, and no auto margin is set, so it sits immediately beside it rather\n than being pushed to the edge of the bar. */\n order: 1;\n min-width: 0;\n font-family: inherit;\n font-size: 12px;\n line-height: 1;\n}\n.dsh-subscription-usage-chip-trigger {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n padding: 2px 6px;\n border: 0;\n border-radius: var(--dsw-radius-sm, 6px);\n background: transparent;\n color: var(--dsw-alias-label-secondary, #8a8f98);\n font: inherit;\n cursor: pointer;\n}\n.dsh-subscription-usage-chip-trigger:hover {\n background: var(--dsw-alias-interactive-bg-hover, #ffffff14);\n}\n.dsh-subscription-usage-chip-trigger:focus-visible {\n outline: 2px solid var(--dsw-alias-border-focus, #4d6bfe);\n outline-offset: 1px;\n}\n.dsh-subscription-usage-chip-label {\n overflow: hidden;\n max-width: 6ch;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.dsh-subscription-usage-chip-cell {\n display: inline-flex;\n align-items: baseline;\n gap: 2px;\n}\n.dsh-subscription-usage-chip-window {\n opacity: 0.75;\n}\n.dsh-subscription-usage-chip-value {\n color: var(--dsw-alias-label-primary, #e8e8e8);\n font-variant-numeric: tabular-nums;\n}\n.dsh-subscription-usage-chip-account {\n display: flex;\n flex-direction: column;\n gap: 6px;\n /* A hairline between accounts, so two sign-ins read as two groups not one run. */\n padding-top: 6px;\n border-top: 1px solid var(--dsw-alias-border-secondary, #ffffff14);\n}\n.dsh-subscription-usage-chip-account:first-of-type {\n padding-top: 0;\n border-top: 0;\n}\n.dsh-subscription-usage-chip-panel {\n position: absolute;\n bottom: calc(100% + 6px);\n right: 0;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 10px;\n min-width: 180px;\n padding: 12px 14px;\n border: 1px solid var(--dsw-alias-border-secondary, #ffffff1f);\n border-radius: var(--dsw-radius-md, 10px);\n background: var(--dsw-alias-bg-layer-2, #26262a);\n box-shadow: var(--dsw-elevation-prominent, 0 12px 32px #0007);\n color: var(--dsw-alias-label-primary, #e8e8e8);\n white-space: nowrap;\n}\n.dsh-subscription-usage-chip-full {\n font-size: 11px;\n color: var(--dsw-alias-label-secondary, #8a8f98);\n}\n.dsh-subscription-usage-chip-meter {\n display: flex;\n flex-direction: column;\n gap: 5px;\n}\n.dsh-subscription-usage-chip-meterHead {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 12px;\n font-size: 12px;\n}\n.dsh-subscription-usage-chip-detail {\n color: var(--dsw-alias-label-secondary, #8a8f98);\n font-variant-numeric: tabular-nums;\n}\n.dsh-subscription-usage-chip-track {\n display: block;\n width: 100%;\n height: 5px;\n border-radius: 999px;\n /* As on the settings meter: an unspent window has to show its track, so the neutral and\n its ring are explicit rather than a layer token that may match the panel. */\n background: #8a8f9826;\n box-shadow: inset 0 0 0 1px #8a8f9833;\n overflow: hidden;\n}\n.dsh-subscription-usage-chip-fill {\n display: block;\n height: 100%;\n background: var(--dsw-alias-state-success-primary, #067647);\n}\n";
33
+ /**
34
+ * The composer's quick-mode button stylesheet.
35
+ *
36
+ * It renders in the composer's own control row — the same row as the model selector —
37
+ * so it borrows that row's geometry: a compact, borderless control that only colours on
38
+ * hover and when the mode is on. Tokens carry fallbacks for hosts that define none.
39
+ */
40
+ export declare const fastModeCss = "\n.dsh-subscription-fast-mode {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n margin: 0;\n padding: 3px;\n border: 0;\n border-radius: var(--dsw-radius-sm, 6px);\n background: transparent;\n color: var(--dsw-alias-label-secondary, #8a8f98);\n font: inherit;\n font-size: 12px;\n line-height: 1;\n cursor: pointer;\n}\n.dsh-subscription-fast-mode:hover {\n background: var(--dsw-alias-interactive-bg-hover, #ffffff14);\n}\n/* A click has to look like one, even before the selection lands. */\n.dsh-subscription-fast-mode:active {\n background: var(--dsw-alias-interactive-bg-pressed, #ffffff2e);\n}\n.dsh-subscription-fast-mode[data-pending=\"true\"] {\n opacity: 0.7;\n cursor: progress;\n}\n/* A switch the host refused is marked rather than left looking merely off, so a failure\n is visible where it happened; the reason rides the title and the console. */\n.dsh-subscription-fast-mode[data-failed=\"true\"] {\n color: var(--dsw-alias-state-error-primary, #b42318);\n}\n.dsh-subscription-fast-mode:focus-visible {\n outline: 2px solid var(--dsw-alias-border-focus, #4d6bfe);\n outline-offset: 1px;\n}\n/* Off: the bolt is dim, so an available mode reads as an invitation, not a state. */\n.dsh-subscription-fast-mode[data-active=\"false\"] .dsh-subscription-fast-mode-bolt {\n opacity: 0.55;\n}\n/* On: the bolt takes the accent and glows, which is the only thing distinguishing the\n fast variant from its base \u2014 both answer with the same model name. */\n.dsh-subscription-fast-mode[data-active=\"true\"] .dsh-subscription-fast-mode-bolt {\n color: var(--dsw-alias-state-warning-primary, #f5a524);\n filter: drop-shadow(0 0 6px var(--dsw-alias-state-warning-primary, #f5a52455));\n}\n.dsh-subscription-fast-mode-reason {\n max-width: 14ch;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 11px;\n}\n";
@@ -394,3 +394,64 @@ export const usageChipCss = `
394
394
  background: var(--dsw-alias-state-success-primary, #067647);
395
395
  }
396
396
  `;
397
+ /**
398
+ * The composer's quick-mode button stylesheet.
399
+ *
400
+ * It renders in the composer's own control row — the same row as the model selector —
401
+ * so it borrows that row's geometry: a compact, borderless control that only colours on
402
+ * hover and when the mode is on. Tokens carry fallbacks for hosts that define none.
403
+ */
404
+ export const fastModeCss = `
405
+ .dsh-subscription-fast-mode {
406
+ display: inline-flex;
407
+ align-items: center;
408
+ gap: 4px;
409
+ margin: 0;
410
+ padding: 3px;
411
+ border: 0;
412
+ border-radius: var(--dsw-radius-sm, 6px);
413
+ background: transparent;
414
+ color: var(--dsw-alias-label-secondary, #8a8f98);
415
+ font: inherit;
416
+ font-size: 12px;
417
+ line-height: 1;
418
+ cursor: pointer;
419
+ }
420
+ .dsh-subscription-fast-mode:hover {
421
+ background: var(--dsw-alias-interactive-bg-hover, #ffffff14);
422
+ }
423
+ /* A click has to look like one, even before the selection lands. */
424
+ .dsh-subscription-fast-mode:active {
425
+ background: var(--dsw-alias-interactive-bg-pressed, #ffffff2e);
426
+ }
427
+ .dsh-subscription-fast-mode[data-pending="true"] {
428
+ opacity: 0.7;
429
+ cursor: progress;
430
+ }
431
+ /* A switch the host refused is marked rather than left looking merely off, so a failure
432
+ is visible where it happened; the reason rides the title and the console. */
433
+ .dsh-subscription-fast-mode[data-failed="true"] {
434
+ color: var(--dsw-alias-state-error-primary, #b42318);
435
+ }
436
+ .dsh-subscription-fast-mode:focus-visible {
437
+ outline: 2px solid var(--dsw-alias-border-focus, #4d6bfe);
438
+ outline-offset: 1px;
439
+ }
440
+ /* Off: the bolt is dim, so an available mode reads as an invitation, not a state. */
441
+ .dsh-subscription-fast-mode[data-active="false"] .dsh-subscription-fast-mode-bolt {
442
+ opacity: 0.55;
443
+ }
444
+ /* On: the bolt takes the accent and glows, which is the only thing distinguishing the
445
+ fast variant from its base — both answer with the same model name. */
446
+ .dsh-subscription-fast-mode[data-active="true"] .dsh-subscription-fast-mode-bolt {
447
+ color: var(--dsw-alias-state-warning-primary, #f5a524);
448
+ filter: drop-shadow(0 0 6px var(--dsw-alias-state-warning-primary, #f5a52455));
449
+ }
450
+ .dsh-subscription-fast-mode-reason {
451
+ max-width: 14ch;
452
+ overflow: hidden;
453
+ text-overflow: ellipsis;
454
+ white-space: nowrap;
455
+ font-size: 11px;
456
+ }
457
+ `;