dsh-cline-pass 0.2.3 → 0.2.4

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 (2) hide show
  1. package/lib/client.js +205 -2
  2. package/package.json +3 -2
package/lib/client.js CHANGED
@@ -241,6 +241,25 @@ window.__ModuleLoader__.load({
241
241
  '.cp-usage-model-tokens{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);min-width:52px;text-align:right}',
242
242
  '.cp-check{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}',
243
243
  '.cp-tools-label{cursor:pointer;gap:8px}',
244
+ // The usage pill beside the model selector. It borrows the panel's ramp and
245
+ // track treatment so a reading looks the same in both places.
246
+ '.cp-pill{position:relative;display:flex;align-items:center;font-size:12px;line-height:1.4}',
247
+ '.cp-pill-btn{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:2px 9px;font:inherit;font-size:11.5px;cursor:pointer;white-space:nowrap;font-variant-numeric:tabular-nums}',
248
+ '.cp-pill-btn:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}',
249
+ '.cp-pill-btn[aria-expanded=true]{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}',
250
+ '.cp-pill-pop{position:absolute;bottom:calc(100% + 8px);right:0;z-index:40;min-width:236px}',
251
+ '.cp-pill-card{background:var(--dsw-alias-bg-layer-1);border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:12px 14px;display:flex;flex-direction:column;gap:9px}',
252
+ '.cp-pill-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
253
+ '.cp-pill-row{display:flex;flex-direction:column;gap:4px}',
254
+ '.cp-pill-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--dsw-alias-label-primary)}',
255
+ '.cp-pill-percent{font-variant-numeric:tabular-nums;font-weight:600}',
256
+ '.cp-pill-track{height:4px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}',
257
+ '.cp-pill-fill{height:100%;border-radius:999px}',
258
+ '.cp-pill-note,.cp-pill-at{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
259
+ '.cp-pill-at{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:8px}',
260
+ '.cp-pill-fail{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
261
+ '.cp-pill-retry{border:0;background:0 0;color:var(--dsw-alias-brand-primary);font:inherit;font-size:11.5px;cursor:pointer;padding:0}',
262
+ '.cp-pill-retry:hover{text-decoration:underline}',
244
263
  '.cp-model-hidden .cp-mono,.cp-model-hidden .cp-check{opacity:.55}',
245
264
  ].join('')
246
265
 
@@ -306,6 +325,12 @@ window.__ModuleLoader__.load({
306
325
  usageHours: '{n} 小时',
307
326
  usageMinutes: '{n} 分钟',
308
327
  usageRefresh: '刷新',
328
+ usagePillTitle: 'Cline Pass 用量',
329
+ usagePillUnavailable: '用量不可用',
330
+ usagePillRetry: '重试',
331
+ usagePillUpdatedAt: '更新于 {time}',
332
+ usagePillLabel: 'Pass',
333
+ usagePillWeekly: '周',
309
334
  usageLoading: '正在读取官方额度…',
310
335
  usageUnavailable: '暂时读不到官方额度。',
311
336
  usageEmpty: '官方未返回额度窗口。',
@@ -389,6 +414,12 @@ window.__ModuleLoader__.load({
389
414
  usageHours: '{n}h',
390
415
  usageMinutes: '{n}m',
391
416
  usageRefresh: 'Refresh',
417
+ usagePillTitle: 'Cline Pass usage',
418
+ usagePillUnavailable: 'Usage unavailable',
419
+ usagePillRetry: 'Retry',
420
+ usagePillUpdatedAt: 'Updated {time}',
421
+ usagePillLabel: 'Pass',
422
+ usagePillWeekly: 'wk',
392
423
  usageLoading: 'Reading the official quota…',
393
424
  usageUnavailable: 'The official quota is unavailable right now.',
394
425
  usageEmpty: 'The gateway reported no quota windows.',
@@ -1839,10 +1870,164 @@ window.__ModuleLoader__.load({
1839
1870
 
1840
1871
  //#endregion
1841
1872
 
1873
+ //#region usage pill
1874
+
1875
+ /**
1876
+ * The Cline Pass quota, next to the model selector.
1877
+ *
1878
+ * Only mounted while the selected model belongs to this provider: the
1879
+ * decision the pill informs is "should I use this model right now, and how
1880
+ * much of the window is left", and for any other model the reading is
1881
+ * irrelevant and the request would be wasted. The gate is the whole reason
1882
+ * this is a pill rather than a permanent widget.
1883
+ */
1884
+ function UsagePill(props) {
1885
+ const state = React.useSyncExternalStore(props.directory.subscribe, props.directory.getSnapshot, props.directory.getSnapshot)
1886
+ const current = state.current
1887
+ if (current === null || current === undefined || String(current.provider) !== String(props.route())) return null
1888
+ return h(UsagePillActive, props)
1889
+ }
1890
+
1891
+ /** The pill itself: a compact reading, expanding into the window breakdown. */
1892
+ function UsagePillActive(props) {
1893
+ const t = props.t
1894
+ const [snapshot, setSnapshot] = React.useState(null)
1895
+ const [failed, setFailed] = React.useState(false)
1896
+ const [open, setOpen] = React.useState(false)
1897
+ const root = React.useRef(null)
1898
+ // Kept across renders so the timer's callback always sees the latest one.
1899
+ const read = React.useRef(() => {})
1900
+
1901
+ React.useEffect(() => {
1902
+ let alive = true
1903
+ let busy = false
1904
+ /**
1905
+ * One reading, shared by the first paint, the timer and the retry.
1906
+ *
1907
+ * A hidden tab is skipped rather than served: a background poll keeps a
1908
+ * request alive for a number nobody is looking at, and the visibility
1909
+ * handler below refreshes the moment the tab comes back.
1910
+ */
1911
+ const load = async (manual) => {
1912
+ if (busy) return
1913
+ if (manual !== true && typeof document !== 'undefined' && document.visibilityState === 'hidden') return
1914
+ busy = true
1915
+ try {
1916
+ const value = await props.readUsage()
1917
+ if (alive) { setSnapshot({ value, at: Date.now() }); setFailed(false) }
1918
+ } catch {
1919
+ // The last good reading stays on screen — a failed poll is not worth
1920
+ // blanking a number that was true a minute ago.
1921
+ if (alive) setFailed(true)
1922
+ } finally {
1923
+ busy = false
1924
+ }
1925
+ }
1926
+ read.current = () => { void load(true) }
1927
+ void load(true)
1928
+ const timer = setInterval(() => { void load(false) }, 60000)
1929
+ const onVisible = () => { if (document.visibilityState === 'visible') void load(false) }
1930
+ document.addEventListener('visibilitychange', onVisible)
1931
+ return () => {
1932
+ alive = false
1933
+ read.current = () => {}
1934
+ clearInterval(timer)
1935
+ document.removeEventListener('visibilitychange', onVisible)
1936
+ }
1937
+ }, [props.readUsage])
1938
+
1939
+ // A click anywhere else closes the popover, which is what a one-shot
1940
+ // disclosure is expected to do; the listener only exists while it is open.
1941
+ React.useEffect(() => {
1942
+ if (!open) return undefined
1943
+ const onDown = (event) => {
1944
+ if (root.current !== null && event.target instanceof Node && !root.current.contains(event.target)) setOpen(false)
1945
+ }
1946
+ document.addEventListener('mousedown', onDown)
1947
+ return () => document.removeEventListener('mousedown', onDown)
1948
+ }, [open])
1949
+
1950
+ const accounts = snapshot?.value?.usage?.accounts ?? []
1951
+ const account = accounts.find((entry) => entry.ok === true) ?? accounts[0]
1952
+ const limits = account?.limits ?? []
1953
+ const byType = new Map(limits.map((limit) => [String(limit.type), limit]))
1954
+ const percentOf = (type) => {
1955
+ const limit = byType.get(type)
1956
+ return limit === undefined ? null : Math.max(0, Math.min(100, Math.round(Number(limit.percentUsed ?? 0))))
1957
+ }
1958
+ const five = percentOf('five_hour')
1959
+ const weekly = percentOf('weekly')
1960
+
1961
+ // The collapsed reading names the two windows a user paces against; a
1962
+ // missing one is dropped rather than shown as an empty slot.
1963
+ const parts = []
1964
+ if (five !== null) parts.push(`${t('usageFiveHour')} ${five}%`)
1965
+ if (weekly !== null) parts.push(`${t('usagePillWeekly')} ${weekly}%`)
1966
+ const summary = [t('usagePillLabel'), ...parts].join(' · ')
1967
+
1968
+ const rows = limits.length === 0 ? null : USAGE_WINDOWS
1969
+ .map(([type, key]) => [type, key, byType.get(type)])
1970
+ .filter(([, , limit]) => limit !== undefined)
1971
+ .concat(limits
1972
+ .filter((limit) => !USAGE_WINDOWS.some(([type]) => type === String(limit.type)))
1973
+ .map((limit) => [String(limit.type), '', limit]))
1974
+
1975
+ const body = failed && snapshot === null
1976
+ ? h('div', { key: 'fail', className: 'cp-pill-fail' }, [
1977
+ h('span', { key: 'm' }, t('usagePillUnavailable')),
1978
+ h('button', { key: 'r', type: 'button', className: 'cp-pill-retry', onClick: () => read.current() }, t('usagePillRetry')),
1979
+ ])
1980
+ : h('div', { key: 'card', className: 'cp-pill-card' }, [
1981
+ h('div', { key: 'title', className: 'cp-pill-title' }, t('usagePillTitle')),
1982
+ rows === null
1983
+ ? h('div', { key: 'empty', className: 'cp-pill-fail' }, t('usagePillUnavailable'))
1984
+ : rows.map(([type, key, limit]) => {
1985
+ const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
1986
+ const left = countdown(t, limit.resetsAt)
1987
+ return h('div', { key: type, className: 'cp-pill-row' }, [
1988
+ h('div', { key: 'head', className: 'cp-pill-row-head' }, [
1989
+ h('span', { key: 'l' }, key === '' ? type : t(key)),
1990
+ h('span', { key: 'v', className: 'cp-pill-percent' }, `${Math.round(percent)}%`),
1991
+ ]),
1992
+ h('div', { key: 'track', className: 'cp-pill-track' },
1993
+ h('div', { key: 'fill', className: 'cp-pill-fill', style: { width: `${percent}%`, background: usageColor(percent) } })),
1994
+ left === '' ? null : h('div', { key: 'note', className: 'cp-pill-note' }, fill(t('usageResetsIn'), { t: left })),
1995
+ ])
1996
+ }),
1997
+ snapshot === null ? null : h('div', { key: 'at', className: 'cp-pill-at' }, [
1998
+ h('span', { key: 's' }, fill(t('usagePillUpdatedAt'), { time: stamp(snapshot.at) })),
1999
+ h('button', { key: 'r', type: 'button', className: 'cp-pill-retry', onClick: () => read.current() }, t('usageRefresh')),
2000
+ ]),
2001
+ ])
2002
+
2003
+ return h('div', { key: 'pill', ref: root, className: 'cp-pill' }, [
2004
+ h('button', {
2005
+ key: 'btn',
2006
+ type: 'button',
2007
+ className: 'cp-pill-btn',
2008
+ 'aria-expanded': open,
2009
+ onClick: () => setOpen((was) => !was),
2010
+ }, snapshot === null ? t('usagePillTitle') : summary === '' ? t('usagePillTitle') : summary),
2011
+ open ? h('div', { key: 'pop', className: 'cp-pill-pop' }, body) : null,
2012
+ ])
2013
+ }
2014
+
2015
+ /** A local timestamp, matching the panel's own reading of "updated at". */
2016
+ function stamp(ms) {
2017
+ const at = new Date(Number(ms ?? 0))
2018
+ const pad = (n) => String(n).padStart(2, '0')
2019
+ return `${at.getFullYear()}/${at.getMonth() + 1}/${at.getDate()} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
2020
+ }
2021
+
2022
+ //#endregion
2023
+
1842
2024
  //#region plugin
1843
2025
 
1844
- /** The slot registry and the browser RPC transport. */
1845
- const inject = ['slots', 'connection']
2026
+ /**
2027
+ * The slot registry, the browser RPC transport, and the model-selection
2028
+ * directory the usage pill reads the selected provider from.
2029
+ */
2030
+ const inject = ['slots', 'connection', 'modelDirectories']
1846
2031
 
1847
2032
  /**
1848
2033
  * Mount the Cline Pass configuration surface.
@@ -1903,6 +2088,24 @@ window.__ModuleLoader__.load({
1903
2088
  inject: face,
1904
2089
  }, renderQuick))
1905
2090
 
2091
+ // 3. The quota reading beside the model selector. It mounts for every
2092
+ // session but renders nothing unless the selected model is this
2093
+ // provider's, so no other model pays for a usage request.
2094
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
2095
+ name: 'conversation.input.right',
2096
+ id: `${NS}-usage`,
2097
+ order: 100,
2098
+ inject: (sessionId) => ({
2099
+ directory: ctx.modelDirectories.directoryFor(sessionId).store,
2100
+ // The route name is configuration, so it is only known once a state
2101
+ // read has landed. Read it at render time rather than capturing it:
2102
+ // the pill can mount before the first read completes.
2103
+ route: () => controller.store.getSnapshot().data?.provider ?? NS,
2104
+ readUsage: async () => await controller.call('usage', {}),
2105
+ t: translate,
2106
+ }),
2107
+ }, UsagePill))
2108
+
1906
2109
  // The first read is lazy (each component asks for it), because the RPC
1907
2110
  // transport only exists once Connection has activated.
1908
2111
  void controller.refresh()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,7 +52,8 @@
52
52
  "platform": "web",
53
53
  "inject": [
54
54
  "@deepseek-ai/dsh-client-ui-settings",
55
- "@deepseek-ai/dsh-client-connection"
55
+ "@deepseek-ai/dsh-client-connection",
56
+ "@deepseek-ai/dsh-client-ui-model-selection"
56
57
  ]
57
58
  },
58
59
  "compatibility": {