dsh-plugin-subscriptions 0.4.1 → 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.
@@ -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);
@@ -323,7 +324,12 @@ export function SubscriptionsSection(props) {
323
324
  setProviderError(provider, undefined);
324
325
  try {
325
326
  const response = await callSubscriptionsAuth(rpc, 'login', { provider });
326
- if (typeof response.authorizeUrl !== 'string' || response.authorizeUrl === '') {
327
+ if (typeof response.authorizeUrl === 'string' && response.authorizeUrl === '') {
328
+ // Instant login (e.g. imported from Claude Code credentials)
329
+ await refresh();
330
+ return;
331
+ }
332
+ if (typeof response.authorizeUrl !== 'string') {
327
333
  throw new SubscriptionsAuthError(t('loginMissingUrl'));
328
334
  }
329
335
  window.open(response.authorizeUrl, '_blank', 'noopener');
@@ -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
  }
@@ -40,6 +40,13 @@ export declare const en: {
40
40
  generatingVideo: string;
41
41
  videoLoading: string;
42
42
  videoLoadFailed: string;
43
+ speed: string;
44
+ speedStandard: string;
45
+ speedStandardDescription: string;
46
+ speedFast: string;
47
+ speedFastDescription: string;
48
+ commandFast: string;
49
+ commandFastUnavailable: string;
43
50
  };
44
51
  /** zh strings, one per {@link en} key. */
45
52
  export declare const zh: {
@@ -82,6 +89,13 @@ export declare const zh: {
82
89
  generatingVideo: string;
83
90
  videoLoading: string;
84
91
  videoLoadFailed: string;
92
+ speed: string;
93
+ speedStandard: string;
94
+ speedStandardDescription: string;
95
+ speedFast: string;
96
+ speedFastDescription: string;
97
+ commandFast: string;
98
+ commandFastUnavailable: string;
85
99
  };
86
100
  /** The Subscriptions namespace key union (en is the key-set source of truth). */
87
101
  export type SubscriptionsKey = keyof typeof en;
@@ -40,6 +40,13 @@ export const en = {
40
40
  generatingVideo: 'Generating video…',
41
41
  videoLoading: 'Loading video…',
42
42
  videoLoadFailed: 'Video failed to load: {message}',
43
+ speed: 'Speed',
44
+ speedStandard: 'Standard',
45
+ speedStandardDescription: 'Default speed',
46
+ speedFast: 'Fast',
47
+ speedFastDescription: '1.5x speed, more usage',
48
+ commandFast: 'Switch the Codex speed tier (Standard/Fast)',
49
+ commandFastUnavailable: 'The current model has no fast tier; /fast only works on Codex models whose catalog advertises one',
43
50
  };
44
51
  /** zh strings, one per {@link en} key. */
45
52
  export const zh = {
@@ -82,4 +89,11 @@ export const zh = {
82
89
  generatingVideo: '正在生成视频…',
83
90
  videoLoading: '视频加载中…',
84
91
  videoLoadFailed: '视频加载失败:{message}',
92
+ speed: '速度',
93
+ speedStandard: '标准',
94
+ speedStandardDescription: '默认速度',
95
+ speedFast: '快速',
96
+ speedFastDescription: '约 1.5 倍速度,消耗更多用量',
97
+ commandFast: '切换 Codex 速度档(标准/快速)',
98
+ commandFastUnavailable: '当前模型不支持快速档;/fast 仅对目录声明了 fast tier 的 Codex 模型可用',
85
99
  };