dsh-plugin-subscriptions 0.4.2 → 0.5.1

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.
Files changed (44) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +18 -6
  3. package/lib/auth/device-flow.d.ts +64 -0
  4. package/lib/auth/device-flow.js +176 -0
  5. package/lib/auth/oauth-flow.js +1 -1
  6. package/lib/auth/rpc.d.ts +21 -2
  7. package/lib/auth/rpc.js +23 -3
  8. package/lib/auth/store.d.ts +20 -2
  9. package/lib/auth/store.js +45 -9
  10. package/lib/client/ImageGallery.d.ts +54 -0
  11. package/lib/client/ImageGallery.js +112 -0
  12. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  13. package/lib/client/ImageGenerateToolview.js +2 -2
  14. package/lib/client/SpeedSelect.d.ts +48 -0
  15. package/lib/client/SpeedSelect.js +173 -0
  16. package/lib/client/SubscriptionsSection.d.ts +10 -1
  17. package/lib/client/SubscriptionsSection.js +48 -5
  18. package/lib/client/index.d.ts +1 -0
  19. package/lib/client/index.js +42 -0
  20. package/lib/client/locales.d.ts +22 -0
  21. package/lib/client/locales.js +22 -0
  22. package/lib/client.js +679 -77
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.d.ts +3 -2
  25. package/lib/index.js +1868 -183
  26. package/lib/providers/catalog-store.js +19 -0
  27. package/lib/providers/claude.d.ts +20 -1
  28. package/lib/providers/claude.js +58 -31
  29. package/lib/providers/codex.d.ts +27 -0
  30. package/lib/providers/codex.js +117 -27
  31. package/lib/providers/common.d.ts +34 -1
  32. package/lib/providers/common.js +48 -1
  33. package/lib/providers/copilot.d.ts +315 -0
  34. package/lib/providers/copilot.js +786 -0
  35. package/lib/providers/grok.d.ts +7 -2
  36. package/lib/providers/grok.js +46 -18
  37. package/lib/tools/image-generate.js +3 -10
  38. package/lib/translate/anthropic.d.ts +47 -6
  39. package/lib/translate/anthropic.js +135 -20
  40. package/lib/translate/chat-completions.d.ts +120 -0
  41. package/lib/translate/chat-completions.js +363 -0
  42. package/lib/translate/responses.d.ts +49 -5
  43. package/lib/translate/responses.js +40 -7
  44. package/package.json +6 -3
@@ -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
+ };
@@ -1,7 +1,7 @@
1
1
  import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
2
2
  import type { SubscriptionsKey } from './locales.js';
3
3
  /** Subscription provider ids, fixed by the node half's OAuth adapters. */
4
- export type SubscriptionProvider = 'codex' | 'claude' | 'grok';
4
+ export type SubscriptionProvider = 'codex' | 'claude' | 'grok' | 'copilot';
5
5
  /** One provider's login state as answered by the `status` endpoint. */
6
6
  export interface ProviderStatus {
7
7
  loggedIn: boolean;
@@ -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}).
@@ -24,18 +24,20 @@ const PROVIDERS = [
24
24
  { id: 'codex', name: 'Codex (ChatGPT)' },
25
25
  { id: 'claude', name: 'Claude' },
26
26
  { id: 'grok', name: 'Grok (X Premium)' },
27
+ { id: 'copilot', name: 'GitHub Copilot' },
27
28
  ];
28
29
  /** Business error returned by the `/subscriptions-auth` channel (error branch message). */
29
30
  class SubscriptionsAuthError extends Error {
30
31
  }
31
32
  /**
32
33
  * Call one `/subscriptions-auth` endpoint and unwrap the business result.
34
+ * Shared by the settings section and the composer Speed toggle.
33
35
  * @param rpc - Connection RPC caller.
34
36
  * @param endpoint - channel-relative endpoint.
35
37
  * @param payload - channel-owned request payload.
36
38
  * @returns the success value, cast by the caller to the endpoint's shape.
37
39
  */
38
- async function callSubscriptionsAuth(rpc, endpoint, payload) {
40
+ export async function callSubscriptionsAuth(rpc, endpoint, payload) {
39
41
  let result;
40
42
  try {
41
43
  result = await rpc.call(SUBSCRIPTIONS_AUTH_CHANNEL, endpoint, payload);
@@ -121,6 +123,15 @@ const styles = {
121
123
  padding: '0 10px', font: 'inherit', fontSize: 14, lineHeight: '22px',
122
124
  background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
123
125
  },
126
+ deviceCode: {
127
+ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6,
128
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
129
+ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-1)',
130
+ },
131
+ deviceCodeText: {
132
+ fontFamily: 'monospace', fontSize: 18, lineHeight: '24px', letterSpacing: 2,
133
+ color: 'var(--dsw-alias-label-primary)', userSelect: 'all',
134
+ },
124
135
  };
125
136
  /** Status dot color for one provider state. */
126
137
  function dotColor(status) {
@@ -188,8 +199,11 @@ export function SubscriptionsSection(props) {
188
199
  const [statuses, setStatuses] = useState({});
189
200
  const [errors, setErrors] = useState({});
190
201
  const [manualDrafts, setManualDrafts] = useState({
191
- codex: '', claude: '', grok: '',
202
+ codex: '', claude: '', grok: '', copilot: '',
192
203
  });
204
+ /** Pending device-flow codes (copilot), shown while the attempt polls. */
205
+ const [deviceCodes, setDeviceCodes] = useState({});
206
+ const [copiedCode, setCopiedCode] = useState(undefined);
193
207
  const [usages, setUsages] = useState({});
194
208
  const [usageErrors, setUsageErrors] = useState({});
195
209
  const [usageLoading, setUsageLoading] = useState({});
@@ -234,8 +248,17 @@ export function SubscriptionsSection(props) {
234
248
  setStatuses(response.providers);
235
249
  for (const { id } of PROVIDERS) {
236
250
  const status = response.providers[id];
237
- if (status.loggedIn || !status.busy)
251
+ if (status.loggedIn || !status.busy) {
238
252
  stopPolling(id);
253
+ // The attempt settled (success, timeout, or cancel): drop the code card.
254
+ setDeviceCodes((prev) => {
255
+ if (prev[id] === undefined)
256
+ return prev;
257
+ const next = { ...prev };
258
+ delete next[id];
259
+ return next;
260
+ });
261
+ }
239
262
  }
240
263
  }, [rpc, stopPolling]);
241
264
  const startPolling = useCallback((provider) => {
@@ -331,11 +354,18 @@ export function SubscriptionsSection(props) {
331
354
  if (typeof response.authorizeUrl !== 'string') {
332
355
  throw new SubscriptionsAuthError(t('loginMissingUrl'));
333
356
  }
334
- window.open(response.authorizeUrl, '_blank', 'noopener');
335
357
  if (!mountedRef.current)
336
358
  return;
337
359
  // Optimistic busy so Cancel and the manual fallback appear before the first poll tick.
338
360
  setStatuses(prev => ({ ...prev, [provider]: { ...prev[provider], busy: true, loggedIn: false } }));
361
+ if (typeof response.userCode === 'string' && response.userCode.length > 0) {
362
+ // Device flow: show the code card instead of opening the page blind —
363
+ // the user copies the code first, then opens the verification page.
364
+ setDeviceCodes(prev => ({ ...prev, [provider]: { userCode: response.userCode, verificationUrl: response.authorizeUrl } }));
365
+ }
366
+ else {
367
+ window.open(response.authorizeUrl, '_blank', 'noopener');
368
+ }
339
369
  startPolling(provider);
340
370
  }
341
371
  catch (error) {
@@ -385,12 +415,25 @@ export function SubscriptionsSection(props) {
385
415
  }
386
416
  await refresh();
387
417
  }, [rpc, t, setProviderError, refresh]);
418
+ const copyDeviceCode = useCallback((provider, userCode) => {
419
+ void navigator.clipboard?.writeText(userCode).then(() => {
420
+ if (!mountedRef.current)
421
+ return;
422
+ setCopiedCode(provider);
423
+ setTimeout(() => {
424
+ if (mountedRef.current) {
425
+ setCopiedCode(current => current === provider ? undefined : current);
426
+ }
427
+ }, 1500);
428
+ }).catch(() => undefined);
429
+ }, []);
388
430
  if (rpc === undefined) {
389
431
  return _jsx("p", { style: styles.intro, children: t('unavailable') });
390
432
  }
391
433
  return (_jsxs("div", { style: styles.section, children: [_jsx("p", { style: styles.intro, children: t('intro') }), PROVIDERS.map(({ id, name }) => {
392
434
  const status = statuses[id];
393
435
  const busy = status?.busy === true;
436
+ const deviceCode = deviceCodes[id];
394
437
  const usage = usages[id];
395
438
  const usageError = usageErrors[id];
396
439
  // Providers without a usage endpoint answer supported:false — no block.
@@ -400,6 +443,6 @@ export function SubscriptionsSection(props) {
400
443
  const percent = Math.min(100, Math.max(0, window.usedPercent));
401
444
  return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
402
445
  && ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
403
- })] })), busy && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
446
+ })] })), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
404
447
  })] }));
405
448
  }
@@ -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
  }