dsh-plugin-subscriptions 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,10 @@ Models that advertise reasoning levels get an **Effort** selector in the same me
18
18
 
19
19
  ![Reasoning effort selector](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
20
 
21
+ Codex models whose catalog advertises the fast tier (the codex CLI's fast mode) get a **Speed** toggle in the composer's tool row, next to the model selector — Standard or Fast (`service_tier: priority`), per session. The `/fast` slash command offers the same choice as a popup; it errors with an explanation when the current model has no fast tier.
22
+
23
+ ![Speed toggle with the Standard/Fast menu open](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  The `image_generate` tool renders its result inline in the conversation:
22
26
 
23
27
  ![image_generate renders the image inline](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
package/README.zh.md CHANGED
@@ -18,6 +18,10 @@
18
18
 
19
19
  ![推理等级选择器](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
20
 
21
+ 目录声明了 fast tier(即 codex CLI 的 fast 模式)的 Codex 模型,会在输入框工具行(模型选择器旁)多出一个**速度**开关 —— 标准 / 快速(`service_tier: priority`),按会话生效。`/fast` 斜杠命令提供同样的弹窗选择;当前模型不支持快速档时会提示原因。
22
+
23
+ ![速度开关及其标准/快速菜单](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  `image_generate` 工具生成的图片直接内联显示在对话里:
22
26
 
23
27
  ![image_generate 内联显示生成的图片](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
package/lib/auth/rpc.d.ts CHANGED
@@ -20,6 +20,22 @@ export interface VideoBytesResult {
20
20
  mediaType: string;
21
21
  dataBase64: string;
22
22
  }
23
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
24
+ export type SpeedTier = 'standard' | 'fast';
25
+ /** `speed` endpoint value: the session's choice plus the visibility list. */
26
+ export interface SpeedState {
27
+ /** The session's current speed tier (default `standard`). */
28
+ tier: SpeedTier;
29
+ /** Codex model ids whose catalog advertises a fast tier. */
30
+ fastModels: string[];
31
+ }
32
+ /** Speed state the RPC handler delegates to (in-memory, per session). */
33
+ export interface SpeedController {
34
+ /** Current speed state: the session's tier and the fast-capable codex models. */
35
+ speed(sessionId: string): Promise<SpeedState>;
36
+ /** Set one session's speed tier. */
37
+ setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
38
+ }
23
39
  /** Login state of one provider, as rendered by the Settings page. */
24
40
  export interface ProviderStatus {
25
41
  /** Whether a session exists in the store. */
@@ -83,5 +99,6 @@ export interface AuthController {
83
99
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
84
100
  * @param ctx - the plugin context (headless profiles have no `connection`).
85
101
  * @param controller - the auth operations backing the endpoints.
102
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
86
103
  */
87
- export declare function registerAuthRpc(ctx: Context, controller: AuthController): void;
104
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController): void;
package/lib/auth/rpc.js CHANGED
@@ -42,6 +42,14 @@ function readString(payload, field) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /** Validate the `setSpeed` endpoint's tier. */
46
+ function readSpeedTier(payload) {
47
+ const tier = payload.tier;
48
+ if (tier !== 'standard' && tier !== 'fast') {
49
+ throw new BadRequest('payload.tier must be "standard" or "fast"');
50
+ }
51
+ return tier;
52
+ }
45
53
  /** Validate the `image` endpoint's payload into a full attachment reference. */
46
54
  function readImageRef(payload) {
47
55
  if (typeof payload !== 'object' || payload === null)
@@ -88,7 +96,13 @@ function readVideoName(payload) {
88
96
  }
89
97
  return name;
90
98
  }
91
- async function dispatch(controller, endpoint, payload, signal) {
99
+ /** Validate the session id both speed endpoints carry. */
100
+ function readSessionId(payload) {
101
+ if (typeof payload !== 'object' || payload === null)
102
+ throw new BadRequest('payload must be an object');
103
+ return readString(payload, 'sessionId');
104
+ }
105
+ async function dispatch(controller, speed, endpoint, payload, signal) {
92
106
  switch (endpoint) {
93
107
  case 'status': {
94
108
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -113,6 +127,11 @@ async function dispatch(controller, endpoint, payload, signal) {
113
127
  return ok(await controller.readImage(readImageRef(payload), signal));
114
128
  case 'video':
115
129
  return ok(await controller.readVideo(readVideoName(payload), signal));
130
+ case 'speed':
131
+ return ok(await speed.speed(readSessionId(payload)));
132
+ case 'setSpeed':
133
+ await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
134
+ return ok({ ok: true });
116
135
  default:
117
136
  throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
118
137
  }
@@ -121,8 +140,9 @@ async function dispatch(controller, endpoint, payload, signal) {
121
140
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
122
141
  * @param ctx - the plugin context (headless profiles have no `connection`).
123
142
  * @param controller - the auth operations backing the endpoints.
143
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
124
144
  */
125
- export function registerAuthRpc(ctx, controller) {
145
+ export function registerAuthRpc(ctx, controller, speed) {
126
146
  // `connection` is not in this plugin's inject list (headless compositions
127
147
  // lack it), so its startup order is unconstrained: defer registration until
128
148
  // the service exists instead of probing once at apply time.
@@ -130,7 +150,7 @@ export function registerAuthRpc(ctx, controller) {
130
150
  const connection = ctx.get('connection');
131
151
  ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
132
152
  try {
133
- return await dispatch(controller, endpoint, payload, signal);
153
+ return await dispatch(controller, speed, endpoint, payload, signal);
134
154
  }
135
155
  catch (error) {
136
156
  return failure(error);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Mirror of ImageAttachmentRef (packages/attachment/attachment/src/types.ts);
3
+ * the brand on attachmentId is compile-time only, so string suffices here.
4
+ */
5
+ export interface ImageAttachmentRef {
6
+ attachmentId: string;
7
+ mediaType: string;
8
+ bytes: number;
9
+ width: number;
10
+ height: number;
11
+ name?: string;
12
+ }
13
+ /** Loads a session-authorized durable image URL (resolves to a data/blob URL). */
14
+ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
15
+ /** Lightbox strings forwarded to the opened preview. */
16
+ export interface ImageLightboxLabels {
17
+ dialog: string;
18
+ close: string;
19
+ }
20
+ /** Message-image strings the owner resolves from its own locale namespace. */
21
+ export interface MessageImageLabels {
22
+ /** Fallback display name for an unnamed image. */
23
+ image: string;
24
+ /** Thumbnail tooltip inviting the original-image preview. */
25
+ open: string;
26
+ /** Accessible thumbnail label; receives the image's display name. */
27
+ openNamed: (label: string) => string;
28
+ /** Loading placeholder shown until bytes resolve. */
29
+ loading: string;
30
+ /** Retry-control label shown when the load fails. */
31
+ loadFailed: string;
32
+ /** Lightbox strings forwarded to the opened preview. */
33
+ lightbox: ImageLightboxLabels;
34
+ }
35
+ /**
36
+ * Compact history renderer with retryable loading and click-to-open original
37
+ * preview. A lone image renders at its `singleFit` size; an image among
38
+ * several renders as a fixed 64px square tile.
39
+ */
40
+ export declare function MessageImage({ attachment, load, variant, labels }: {
41
+ attachment: ImageAttachmentRef;
42
+ load: ImageLoader;
43
+ variant: 'single' | 'tile';
44
+ labels: MessageImageLabels;
45
+ }): import("react").JSX.Element;
46
+ /** Wrapping image group: a lone image renders large, several render as 64px
47
+ * square tiles (same rule as the platform gallery). */
48
+ export declare function ImageGallery({ images, load, labels }: {
49
+ images: readonly {
50
+ attachment: ImageAttachmentRef;
51
+ }[];
52
+ load: ImageLoader;
53
+ labels: MessageImageLabels;
54
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Plugin-local image gallery for generated-image toolviews.
4
+ *
5
+ * Since web-app rc.8, `@deepseek-ai/dsh-client-ui-attachment`'s browser module
6
+ * exports only its cordis plugin surface (`apply`/`inject`) — the React
7
+ * components are no longer package values, so importing `ImageGallery` from it
8
+ * yields `undefined` at runtime and crashes the toolview inside the slot error
9
+ * boundary (the whole call row disappears). This module re-implements the
10
+ * gallery contract the toolview needs (loader-driven thumbnails, retry on
11
+ * failure, click-to-open lightbox) with the same sizing rules as the platform
12
+ * component, keeping this plugin independent of harness component exports.
13
+ */
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ /** Display box for a lone image (platform rule): long edge 240px with the
16
+ * rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
17
+ * `object-fit: cover` — and never upscaled past the image's natural size. The
18
+ * crop anchor keeps the top of very tall images and the left of very wide
19
+ * ones, where the informative content usually starts. */
20
+ function singleFit(attachment) {
21
+ const natural = attachment.width / attachment.height;
22
+ const ratio = Math.min(4, Math.max(0.25, natural));
23
+ const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 };
24
+ const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height);
25
+ return {
26
+ width: Math.max(1, Math.round(box.width * scale)),
27
+ height: Math.max(1, Math.round(box.height * scale)),
28
+ objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
29
+ };
30
+ }
31
+ const styles = {
32
+ gallery: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-start' },
33
+ frame: {
34
+ display: 'grid', placeItems: 'center', overflow: 'hidden', padding: 0,
35
+ border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
36
+ background: 'var(--dsw-alias-interactive-bg-hover-solid)', cursor: 'zoom-in',
37
+ },
38
+ tile: { width: 64, height: 64 },
39
+ img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
40
+ loading: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', padding: '0 8px' },
41
+ error: {
42
+ fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', cursor: 'pointer',
43
+ border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
44
+ background: 'transparent', padding: '6px 10px',
45
+ },
46
+ overlay: {
47
+ position: 'fixed', inset: 0, zIndex: 1000, display: 'grid', placeItems: 'center',
48
+ background: 'rgba(0, 0, 0, 0.72)', padding: 24,
49
+ },
50
+ overlayImg: { maxWidth: '92vw', maxHeight: '92vh', objectFit: 'contain', borderRadius: 4 },
51
+ close: {
52
+ position: 'absolute', top: 12, right: 12, width: 32, height: 32, display: 'grid',
53
+ placeItems: 'center', border: 'none', borderRadius: '50%', cursor: 'pointer',
54
+ background: 'rgba(255, 255, 255, 0.16)', color: '#fff', fontSize: 16, lineHeight: 1,
55
+ },
56
+ };
57
+ /**
58
+ * Full-viewport original-image preview: backdrop or close-control click and
59
+ * Escape all dismiss; the image itself is inert so a click on it does not
60
+ * fall through to the backdrop dismissal.
61
+ */
62
+ function ImageLightbox({ src, alt, labels, onClose }) {
63
+ useEffect(() => {
64
+ const onKey = (event) => { if (event.key === 'Escape')
65
+ onClose(); };
66
+ window.addEventListener('keydown', onKey);
67
+ return () => { window.removeEventListener('keydown', onKey); };
68
+ }, [onClose]);
69
+ return (_jsxs("div", { role: "dialog", "aria-label": labels.dialog, style: styles.overlay, onClick: onClose, children: [_jsx("img", { src: src, alt: alt, style: styles.overlayImg, onClick: event => { event.stopPropagation(); } }), _jsx("button", { type: "button", "aria-label": labels.close, style: styles.close, onClick: onClose, children: "\u00D7" })] }));
70
+ }
71
+ /**
72
+ * Compact history renderer with retryable loading and click-to-open original
73
+ * preview. A lone image renders at its `singleFit` size; an image among
74
+ * several renders as a fixed 64px square tile.
75
+ */
76
+ export function MessageImage({ attachment, load, variant, labels }) {
77
+ const [src, setSrc] = useState(null);
78
+ const [error, setError] = useState(false);
79
+ const [open, setOpen] = useState(false);
80
+ // Retry re-arms the one load effect below, so every attempt — first load or
81
+ // retry — runs under the same liveness guard and the same reset.
82
+ const [attempt, setAttempt] = useState(0);
83
+ const retry = useCallback(() => { setAttempt(a => a + 1); }, []);
84
+ const close = useCallback(() => { setOpen(false); }, []);
85
+ const fit = useMemo(() => (variant === 'single' ? singleFit(attachment) : undefined), [attachment, variant]);
86
+ useEffect(() => {
87
+ let live = true;
88
+ setError(false);
89
+ setSrc(null);
90
+ void load(attachment).then((url) => { if (live)
91
+ setSrc(url); }).catch(() => { if (live)
92
+ setError(true); });
93
+ return () => { live = false; };
94
+ }, [attachment, load, attempt]);
95
+ const label = attachment.name ?? labels.image;
96
+ if (error) {
97
+ return _jsx("button", { type: "button", style: styles.error, onClick: retry, children: labels.loadFailed });
98
+ }
99
+ const box = fit === undefined ? styles.tile : { width: fit.width, height: fit.height };
100
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: { ...styles.frame, ...box }, title: labels.open, "aria-label": labels.openNamed(label), onClick: () => { if (src !== null)
101
+ setOpen(true); }, children: src === null
102
+ ? _jsx("span", { style: styles.loading, children: labels.loading })
103
+ : _jsx("img", { src: src, alt: label, style: { ...styles.img, objectPosition: fit?.objectPosition } }) }), open && src !== null && _jsx(ImageLightbox, { src: src, alt: label, labels: labels.lightbox, onClose: close })] }));
104
+ }
105
+ /** Wrapping image group: a lone image renders large, several render as 64px
106
+ * square tiles (same rule as the platform gallery). */
107
+ export function ImageGallery({ images, load, labels }) {
108
+ if (images.length === 0)
109
+ return null;
110
+ const variant = images.length === 1 ? 'single' : 'tile';
111
+ return (_jsx("div", { style: styles.gallery, children: images.map((image, index) => (_jsx(MessageImage, { attachment: image.attachment, load: load, variant: variant, labels: labels }, `${image.attachment.attachmentId}:${index}`))) }));
112
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
2
2
  import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client';
3
- import type { ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment';
3
+ import type { ImageLoader } from './ImageGallery.js';
4
4
  import type { SubscriptionsKey } from './locales.js';
5
5
  /** Mirror of ui-tool's ToolCallOwnerProps (see the module header). */
6
6
  interface ToolCallOwnerProps {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives';
3
- import { ImageGallery } from '@deepseek-ai/dsh-client-ui-attachment';
3
+ import { ImageGallery } from './ImageGallery.js';
4
4
  import { en } from './locales.js';
5
5
  /** Logical RPC channel served by the node half of this plugin. */
6
6
  const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
@@ -133,5 +133,5 @@ export function ImageGenerateToolview(props) {
133
133
  loadFailed: t('imageLoadFailed'),
134
134
  lightbox: { dialog: t('imagePreview'), close: t('imageClose') },
135
135
  };
136
- return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load, align: "start", labels: labels })), settled && !block.isError && images.length === 0 && text !== '' && (_jsx("p", { style: styles.output, children: text }))] }));
136
+ return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load, labels: labels })), settled && !block.isError && images.length === 0 && text !== '' && (_jsx("p", { style: styles.output, children: text }))] }));
137
137
  }
@@ -0,0 +1,48 @@
1
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
3
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
4
+ export type SpeedTier = 'standard' | 'fast';
5
+ /** `speed` endpoint value, mirrored from the node half. */
6
+ export interface SpeedState {
7
+ tier: SpeedTier;
8
+ fastModels: string[];
9
+ }
10
+ /** What {@link SpeedSelect} renders from: visibility plus the current tier. */
11
+ export interface SpeedSelectState {
12
+ visible: boolean;
13
+ tier: SpeedTier;
14
+ }
15
+ /** Injected dependencies of {@link SpeedSelect} (slot `inject`, session-bound). */
16
+ export interface SpeedSelectInjected {
17
+ /** Load the session's speed state; `visible` false keeps the control hidden. */
18
+ loadSpeed: () => Promise<SpeedSelectState>;
19
+ /** Set the session's speed tier; resolves false when the write failed. */
20
+ setSpeed: (tier: SpeedTier) => Promise<boolean>;
21
+ }
22
+ /**
23
+ * Props delivered by the slot outlet: the framework session kit and InputZone
24
+ * owner share (unused — everything arrives session-bound through the inject
25
+ * face), the injected callbacks, and the locale seat.
26
+ */
27
+ export type SpeedSelectProps = PropsRuntime<'conversation.input.right'> & Partial<SpeedSelectInjected> & Partial<PropsLocale<'settings.subscriptions'>>;
28
+ /**
29
+ * The `loadSpeed` half of the inject face: the plugin's own speed state plus
30
+ * the host's current model selection (the visibility gate). A model-RPC
31
+ * failure throws rather than answering "hidden" — the caller keeps its last
32
+ * known state, so a transient failure never locks the toggle away.
33
+ *
34
+ * `sessionId` is a plain string: slot and command contexts brand it through
35
+ * different dsh-session copies, and only the API-client boundary needs one.
36
+ */
37
+ export declare function createSpeedLoader(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['loadSpeed'];
38
+ /** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */
39
+ export declare function createSpeedSetter(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['setSpeed'];
40
+ /**
41
+ * The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
42
+ * that opens a two-row menu (standard/fast with descriptions, check mark on
43
+ * the current tier). The host pushes nothing on a model switch, so the
44
+ * control re-reads on a slow poll with a single-flight guard; a failed read
45
+ * keeps the last known state, so a transient RPC failure can never lock the
46
+ * toggle away (the earlier mount-only load had no recovery path).
47
+ */
48
+ export declare function SpeedSelect({ loadSpeed, setSpeed, t }: SpeedSelectProps): import("react").JSX.Element | null;
@@ -0,0 +1,173 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Codex Speed toggle: one small control in the composer's right tool row
4
+ * (`conversation.input.right`), switching the session between standard routing
5
+ * and the fast (priority) service tier — the Codex desktop app's Speed menu.
6
+ * The choice is per session and lives in the node half (in-memory); this
7
+ * component holds only viewing state. The control renders nothing until the
8
+ * first load proves the session's current model is a codex model whose catalog
9
+ * advertises the fast tier.
10
+ *
11
+ * Every color resolves through a `--dsw-alias-*` design token and every
12
+ * user-visible string goes through the locale `t` of the
13
+ * 'settings.subscriptions' namespace, same as the settings section.
14
+ */
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import { callSubscriptionsAuth } from './SubscriptionsSection.js';
17
+ import { en } from './locales.js';
18
+ /**
19
+ * The `loadSpeed` half of the inject face: the plugin's own speed state plus
20
+ * the host's current model selection (the visibility gate). A model-RPC
21
+ * failure throws rather than answering "hidden" — the caller keeps its last
22
+ * known state, so a transient failure never locks the toggle away.
23
+ *
24
+ * `sessionId` is a plain string: slot and command contexts brand it through
25
+ * different dsh-session copies, and only the API-client boundary needs one.
26
+ */
27
+ export function createSpeedLoader(connection, sessionId) {
28
+ return async () => {
29
+ const state = await callSubscriptionsAuth(connection.rpc, 'speed', { sessionId });
30
+ const { result } = await connection.api.sessions.models({ sessionId: sessionId });
31
+ if (!result.ok)
32
+ throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`);
33
+ const current = result.value.current;
34
+ const visible = current !== null && current.provider === 'codex'
35
+ && state.fastModels.includes(current.model);
36
+ return { visible, tier: state.tier };
37
+ };
38
+ }
39
+ /** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */
40
+ export function createSpeedSetter(connection, sessionId) {
41
+ return tier => callSubscriptionsAuth(connection.rpc, 'setSpeed', { sessionId, tier })
42
+ .then(() => true, () => false);
43
+ }
44
+ /** English-dictionary fallback for a missing inject `t` (standalone renders). */
45
+ function fallbackTranslate(key) {
46
+ return en[key];
47
+ }
48
+ const TIERS = ['standard', 'fast'];
49
+ /**
50
+ * The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
51
+ * that opens a two-row menu (standard/fast with descriptions, check mark on
52
+ * the current tier). Mount and every open reload the host state so a model
53
+ * switch made since the last open self-corrects.
54
+ */
55
+ /** How often the control re-reads the host state (model switches arrive only by asking). */
56
+ const POLL_INTERVAL_MS = 3000;
57
+ /**
58
+ * The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
59
+ * that opens a two-row menu (standard/fast with descriptions, check mark on
60
+ * the current tier). The host pushes nothing on a model switch, so the
61
+ * control re-reads on a slow poll with a single-flight guard; a failed read
62
+ * keeps the last known state, so a transient RPC failure can never lock the
63
+ * toggle away (the earlier mount-only load had no recovery path).
64
+ */
65
+ export function SpeedSelect({ loadSpeed, setSpeed, t }) {
66
+ const translate = t ?? fallbackTranslate;
67
+ const [state, setState] = useState(null);
68
+ const [open, setOpen] = useState(false);
69
+ const [busy, setBusy] = useState(false);
70
+ const rootRef = useRef(null);
71
+ // The inject face may be re-evaluated (new callback identities) on re-render;
72
+ // the poll effect mounts once and reads through this ref, so identity churn
73
+ // neither resets the interval nor multiplies in-flight loads.
74
+ const loadRef = useRef(loadSpeed);
75
+ loadRef.current = loadSpeed;
76
+ useEffect(() => {
77
+ if (loadRef.current === undefined)
78
+ return;
79
+ let cancelled = false;
80
+ let inflight = false;
81
+ const reload = () => {
82
+ const load = loadRef.current;
83
+ if (load === undefined || inflight)
84
+ return;
85
+ inflight = true;
86
+ void load().then((loaded) => { if (!cancelled)
87
+ setState(loaded); }, () => { }).finally(() => { inflight = false; });
88
+ };
89
+ reload();
90
+ const timer = setInterval(reload, POLL_INTERVAL_MS);
91
+ return () => {
92
+ cancelled = true;
93
+ clearInterval(timer);
94
+ };
95
+ }, []);
96
+ useEffect(() => {
97
+ if (!open)
98
+ return;
99
+ const closeOutside = (event) => {
100
+ if (!rootRef.current?.contains(event.target))
101
+ setOpen(false);
102
+ };
103
+ document.addEventListener('mousedown', closeOutside);
104
+ return () => { document.removeEventListener('mousedown', closeOutside); };
105
+ }, [open]);
106
+ if (loadSpeed === undefined || setSpeed === undefined || state === null || !state.visible) {
107
+ return null;
108
+ }
109
+ const choose = (tier) => {
110
+ if (busy)
111
+ return;
112
+ if (tier === state.tier) {
113
+ setOpen(false);
114
+ return;
115
+ }
116
+ setBusy(true);
117
+ void setSpeed(tier).then((ok) => {
118
+ setBusy(false);
119
+ if (ok) {
120
+ setState({ visible: true, tier });
121
+ setOpen(false);
122
+ }
123
+ });
124
+ };
125
+ const show = () => {
126
+ setOpen(true);
127
+ const load = loadRef.current;
128
+ if (load === undefined)
129
+ return;
130
+ void load().then(setState, () => { });
131
+ };
132
+ const tierName = (tier) => translate(tier === 'fast' ? 'speedFast' : 'speedStandard');
133
+ const tierDescription = (tier) => translate(tier === 'fast' ? 'speedFastDescription' : 'speedStandardDescription');
134
+ const triggerLabel = `${translate('speed')} · ${tierName(state.tier)}`;
135
+ return (_jsxs("div", { ref: rootRef, style: styles.root, onKeyDown: (event) => {
136
+ if (event.key === 'Escape' && open) {
137
+ event.preventDefault();
138
+ setOpen(false);
139
+ }
140
+ }, children: [open && (_jsx("div", { style: styles.menu, role: "menu", "aria-label": translate('speed'), children: TIERS.map(tier => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": tier === state.tier, style: styles.item, disabled: busy, onClick: () => { choose(tier); }, children: [_jsx("span", { style: styles.itemCheck, children: tier === state.tier ? '✓' : '' }), _jsxs("span", { style: styles.itemText, children: [_jsx("span", { style: styles.itemName, children: tierName(tier) }), _jsx("span", { style: styles.itemDescription, children: tierDescription(tier) })] })] }, tier))) })), _jsx("button", { type: "button", style: styles.trigger, "aria-haspopup": "menu", "aria-expanded": open, title: triggerLabel, disabled: busy, onClick: () => {
141
+ if (open)
142
+ setOpen(false);
143
+ else
144
+ show();
145
+ }, children: triggerLabel })] }));
146
+ }
147
+ const styles = {
148
+ root: { position: 'relative', display: 'inline-flex' },
149
+ trigger: {
150
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
151
+ background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
152
+ font: 'inherit', fontSize: 12, lineHeight: '18px',
153
+ padding: '2px 8px', cursor: 'pointer', whiteSpace: 'nowrap',
154
+ },
155
+ menu: {
156
+ position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
157
+ minWidth: 180, padding: 4, zIndex: 20,
158
+ background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
159
+ borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2,
160
+ },
161
+ item: {
162
+ display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%',
163
+ border: 'none', borderRadius: 6, background: 'transparent',
164
+ padding: '6px 8px', cursor: 'pointer', font: 'inherit', textAlign: 'left',
165
+ },
166
+ itemCheck: {
167
+ width: 14, flexShrink: 0, fontSize: 12, lineHeight: '18px',
168
+ color: 'var(--dsw-alias-label-primary)',
169
+ },
170
+ itemText: { display: 'flex', flexDirection: 'column' },
171
+ itemName: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-primary)' },
172
+ itemDescription: { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)' },
173
+ };
@@ -35,6 +35,15 @@ export interface SubscriptionsSectionInjected {
35
35
  * renderer erases the share boundary at the render call).
36
36
  */
37
37
  export type SubscriptionsSectionProps = Partial<SubscriptionsSectionInjected>;
38
+ /**
39
+ * Call one `/subscriptions-auth` endpoint and unwrap the business result.
40
+ * Shared by the settings section and the composer Speed toggle.
41
+ * @param rpc - Connection RPC caller.
42
+ * @param endpoint - channel-relative endpoint.
43
+ * @param payload - channel-owned request payload.
44
+ * @returns the success value, cast by the caller to the endpoint's shape.
45
+ */
46
+ export declare function callSubscriptionsAuth<T>(rpc: ConnectionHandle['rpc'], endpoint: string, payload: unknown): Promise<T>;
38
47
  /**
39
48
  * The Subscriptions settings page component.
40
49
  * @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
@@ -30,12 +30,13 @@ class SubscriptionsAuthError extends Error {
30
30
  }
31
31
  /**
32
32
  * Call one `/subscriptions-auth` endpoint and unwrap the business result.
33
+ * Shared by the settings section and the composer Speed toggle.
33
34
  * @param rpc - Connection RPC caller.
34
35
  * @param endpoint - channel-relative endpoint.
35
36
  * @param payload - channel-owned request payload.
36
37
  * @returns the success value, cast by the caller to the endpoint's shape.
37
38
  */
38
- async function callSubscriptionsAuth(rpc, endpoint, payload) {
39
+ export async function callSubscriptionsAuth(rpc, endpoint, payload) {
39
40
  let result;
40
41
  try {
41
42
  result = await rpc.call(SUBSCRIPTIONS_AUTH_CHANNEL, endpoint, payload);
@@ -11,6 +11,7 @@ import type { SubscriptionsKey } from './locales.js';
11
11
  export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './SubscriptionsSection.js';
12
12
  export type { ImageGenerateToolviewInjected, ImageGenerateToolviewProps } from './ImageGenerateToolview.js';
13
13
  export type { VideoGenerateToolviewInjected, VideoGenerateToolviewProps } from './VideoGenerateToolview.js';
14
+ export type { SpeedSelectInjected, SpeedSelectProps, SpeedState, SpeedTier } from './SpeedSelect.js';
14
15
  export type { SubscriptionsKey } from './locales.js';
15
16
  declare module '@deepseek-ai/dsh-client-ui-slots' {
16
17
  interface LocaleNamespaceMap {
@@ -4,6 +4,7 @@
4
4
  import { SubscriptionsSection } from './SubscriptionsSection.js';
5
5
  import { ImageGenerateToolview, createImageLoader } from './ImageGenerateToolview.js';
6
6
  import { VideoGenerateToolview, createVideoLoader } from './VideoGenerateToolview.js';
7
+ import { SpeedSelect, createSpeedLoader, createSpeedSetter } from './SpeedSelect.js';
7
8
  import { en, zh } from './locales.js';
8
9
  /** Dictionary namespace owned by this plugin. */
9
10
  const NS = 'settings.subscriptions';
@@ -53,4 +54,45 @@ export function apply(ctx) {
53
54
  locale: NS,
54
55
  inject: videoToolviewInjected,
55
56
  }, VideoGenerateToolview));
57
+ // The composer Speed toggle (codex fast tier) sits in the right tool row,
58
+ // just left of the model selector; the framework synthesizes its `t` seat
59
+ // from `locale: NS`, and the inject face binds each session's RPC calls.
60
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
61
+ name: 'conversation.input.right',
62
+ id: 'codex-speed',
63
+ order: 0,
64
+ locale: NS,
65
+ inject: (sessionId) => ({
66
+ loadSpeed: createSpeedLoader(connection, sessionId),
67
+ setSpeed: createSpeedSetter(connection, sessionId),
68
+ }),
69
+ }, SpeedSelect));
70
+ // The /fast slash command offers the same Standard/Fast choice as a popup.
71
+ // `available` is synchronous and sees only the session id, so the command
72
+ // stays listed everywhere; `options` throws the friendly gate when the
73
+ // session's current model is not a fast-capable codex model (the same
74
+ // in-popup error posture the /model contribution uses for its guards).
75
+ ctx.inject(['commandUi'], (scope) => {
76
+ const command = scope.get('commandUi');
77
+ scope.effect(() => command.register({
78
+ name: 'fast',
79
+ description: t('commandFast'),
80
+ available: () => true,
81
+ ui: {
82
+ kind: 'popupSelect',
83
+ options: async (session) => {
84
+ const state = await createSpeedLoader(connection, session.sessionId)();
85
+ if (!state.visible)
86
+ throw new Error(t('commandFastUnavailable'));
87
+ return [
88
+ { id: 'standard', label: t('speedStandard'), detail: t('speedStandardDescription') },
89
+ { id: 'fast', label: t('speedFast'), detail: t('speedFastDescription') },
90
+ ].map(option => ({ ...option, active: option.id === state.tier }));
91
+ },
92
+ onSelect: async (option, session) => {
93
+ await createSpeedSetter(connection, session.sessionId)(option.id);
94
+ },
95
+ },
96
+ }), 'dsh-plugin-subscriptions: /fast contribution');
97
+ });
56
98
  }