dsh-cline-pass 0.2.3 → 0.2.5

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/lib/adapter.js CHANGED
@@ -249,31 +249,88 @@ function collectImageParts(blocks, images, target = []) {
249
249
  /**
250
250
  * Convert the harness conversation to wire messages.
251
251
  *
252
- * Tool results become standalone `{ role: 'tool' }` messages. A user message
253
- * carrying an image switches to the OpenAI content-part array; a text-only one
254
- * keeps the plain-string form.
252
+ * Tool results become standalone `{ role: 'tool' }` messages, in both harness
253
+ * shapes: the current one, where the result IS a `tool`-role message carrying
254
+ * `toolCallId`, and the one dsh < 0.2.0 produced, a user message wrapping a
255
+ * `tool-result` block. A user message carrying an image switches to the OpenAI
256
+ * content-part array; a text-only one keeps the plain-string form.
255
257
  *
256
258
  * An image nested in a tool result (read_image, a screenshot tool) has no
257
259
  * OpenAI `tool` wire spelling, so it rides a following user message instead —
258
- * the same shape the shipped adapters use.
260
+ * the same shape the shipped adapters use. Deferring that message until the
261
+ * next non-tool message keeps a parallel group's answers contiguous.
262
+ *
263
+ * `developer` becomes `system`. The harness uses that role for its own
264
+ * system-level notices — the tool-registry change it appends when the tool set
265
+ * moves — and the newer OpenAI spelling is not accepted by every
266
+ * OpenAI-compatible gateway, while `system` is. Sending it as a user turn (what
267
+ * an unhandled role fell through to) also misrepresents it as something the user
268
+ * said.
269
+ *
270
+ * The group guard below is the same rule stated once: an assistant message
271
+ * carrying `tool_calls` must be followed immediately by a `tool` message per id.
272
+ * Any other message that would land inside that run — a notice, a user turn, the
273
+ * image message a tool-result image needs — is held back until the group closes,
274
+ * because a gateway that validates the rule rejects the whole request with
275
+ * "insufficient tool messages following tool_calls message".
259
276
  */
260
277
  function serializeMessages(messages, images) {
261
278
  const wire = []
262
279
  let pendingToolImages = []
280
+ /** Call ids of the most recent assistant tool_calls still awaiting an answer. */
281
+ let awaiting = []
282
+ /** Messages held back so they cannot split the group; emitted in order. */
283
+ let deferred = []
284
+
285
+ const flushDeferred = () => {
286
+ if (deferred.length === 0) return
287
+ for (const held of deferred) wire.push(held)
288
+ deferred = []
289
+ }
290
+ /**
291
+ * Emit a non-tool message.
292
+ *
293
+ * A message that is not a tool answer cannot go out while a group is open, so
294
+ * it waits. With no group open this is a plain push, which is every ordinary
295
+ * conversation and keeps that output unchanged.
296
+ */
297
+ const emit = (message) => {
298
+ if (awaiting.length > 0) deferred.push(message)
299
+ else wire.push(message)
300
+ }
263
301
  const flushToolImages = () => {
264
302
  if (pendingToolImages.length === 0) return
265
- wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
303
+ const message = { role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] }
266
304
  pendingToolImages = []
305
+ emit(message)
267
306
  }
307
+ /** A tool answer: it closes its own id, and the group when it was the last. */
308
+ const answer = (callId, content) => {
309
+ wire.push({ role: 'tool', tool_call_id: callId, content: flattenText(content) || '(no output)' })
310
+ collectImageParts(content, images, pendingToolImages)
311
+ awaiting = awaiting.filter((id) => id !== callId)
312
+ if (awaiting.length === 0) flushDeferred()
313
+ }
314
+
268
315
  for (const message of messages) {
269
- if (message.role === 'system') {
316
+ if (message.role === 'system' || message.role === 'developer') {
270
317
  flushToolImages()
271
- wire.push({ role: 'system', content: flattenText(message.content) })
318
+ emit({ role: 'system', content: flattenText(message.content) })
272
319
  continue
273
320
  }
274
321
  if (message.role === 'assistant') {
275
322
  flushToolImages()
323
+ // A turn boundary. Anything still held belongs before the new turn, and an
324
+ // unanswered group from the previous turn can no longer be answered.
325
+ flushDeferred()
276
326
  wire.push(serializeAssistant(message))
327
+ awaiting = (message.content ?? []).filter((block) => block.type === 'tool-call').map((block) => block.id)
328
+ continue
329
+ }
330
+ // A harness tool result: the call identity is on the message. No flush here,
331
+ // so the answers to one assistant message's parallel calls stay adjacent.
332
+ if (message.role === 'tool') {
333
+ answer(message.toolCallId, message.content)
277
334
  continue
278
335
  }
279
336
  const toolResults = (message.content ?? []).filter((block) => block.type === 'tool-result')
@@ -286,18 +343,16 @@ function serializeMessages(messages, images) {
286
343
  const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
287
344
  if (hasImage || text.length > 0 || toolResults.length === 0) {
288
345
  flushToolImages()
289
- wire.push({ role: 'user', content: hasImage ? parts : text })
346
+ emit({ role: 'user', content: hasImage ? parts : text })
290
347
  }
291
348
  for (const result of toolResults) {
292
- wire.push({
293
- role: 'tool',
294
- tool_call_id: result.toolCallId,
295
- content: flattenText(result.content) || '(no output)',
296
- })
297
- collectImageParts(result.content, images, pendingToolImages)
349
+ answer(result.toolCallId, result.content)
298
350
  }
299
351
  }
300
352
  flushToolImages()
353
+ // A group the history never closed leaves nothing to wait for; whatever was
354
+ // held still belongs in the request, just after the answers that do exist.
355
+ flushDeferred()
301
356
  return wire
302
357
  }
303
358
 
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.5",
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": {