From 0b87f74cd3e29e966e0de02e95e3377c07ea833c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:46:33 +0800 Subject: [PATCH] feat(experimental): Web band for Claude Code mods A Client package mounts the bridge's Remote contribution, watches each session's band through watchBand, draws the serialized tree in the input dock with Claude Code's Box/Text/Button vocabulary on this theme's tokens, and sends button clicks back through pressBand with the generation they were drawn in. An opt-in overlay composes the bridge, the band, and the three example mods for a source launch. --- .../examples/claude-code-mods.overlay.yml | 20 +++ apps/cli/package.json | 4 +- .../claude-code-mods/package.json | 4 + .../claude-code-mods/src/elements.ts | 14 +- .../claude-code-mods/src/index.ts | 6 +- .../claude-code-mods/src/surfaces.ts | 14 +- .../claude-code-mods/src/types.ts | 22 +++ .../client-ui-claude-code-mods/package.json | 89 ++++++++++ .../src/client/Band.module.css | 106 ++++++++++++ .../src/client/Band.tsx | 130 ++++++++++++++ .../src/client/index.ts | 18 ++ .../src/client/locales.ts | 23 +++ .../src/client/mount.ts | 122 ++++++++++++++ .../src/css-modules.d.ts | 5 + .../client-ui-claude-code-mods/src/index.ts | 4 + .../tests/band.client.spec.tsx | 116 +++++++++++++ .../tests/mount.client.spec.ts | 158 ++++++++++++++++++ .../client-ui-claude-code-mods/tsconfig.json | 51 ++++++ .../tsdown.config.ts | 3 + pnpm-lock.yaml | 63 +++++++ tsconfig.base.json | 3 + tsconfig.client.json | 1 + vitest.config.ts | 3 + 23 files changed, 953 insertions(+), 26 deletions(-) create mode 100644 apps/cli/config/examples/claude-code-mods.overlay.yml create mode 100644 packages/experimental/client-ui-claude-code-mods/package.json create mode 100644 packages/experimental/client-ui-claude-code-mods/src/client/Band.module.css create mode 100644 packages/experimental/client-ui-claude-code-mods/src/client/Band.tsx create mode 100644 packages/experimental/client-ui-claude-code-mods/src/client/index.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/src/client/locales.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/src/client/mount.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/src/css-modules.d.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/src/index.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/tests/band.client.spec.tsx create mode 100644 packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts create mode 100644 packages/experimental/client-ui-claude-code-mods/tsconfig.json create mode 100644 packages/experimental/client-ui-claude-code-mods/tsdown.config.ts diff --git a/apps/cli/config/examples/claude-code-mods.overlay.yml b/apps/cli/config/examples/claude-code-mods.overlay.yml new file mode 100644 index 0000000000..5b72d6cd63 --- /dev/null +++ b/apps/cli/config/examples/claude-code-mods.overlay.yml @@ -0,0 +1,20 @@ +# Opt-in: the Claude Code mods bridge, its band above the prompt, and the three +# example mods from Anthropic's "Getting started with Claude Code mods" post. +# Source launch only: the example wrappers are TypeScript under the package's +# examples/ directory. Mods load in list order and the band above the prompt is +# one instance consulted in that order, so the guards that yield when idle come +# before Token Weather, which draws whenever it has a reading. +# +# pnpm dsh web --patch apps/cli/config/examples/claude-code-mods.overlay.yml +# pnpm dsh --profile headless --patch apps/cli/config/examples/claude-code-mods.overlay.yml "task" +- insert: + - id: claude-code-mods + name: '@deepseek-ai/dsh-experimental-claude-code-mods' + - id: claude-code-mod-blast-radius + name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/blast-radius/index.ts' + - id: claude-code-mod-replay-theater + name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/replay-theater/index.ts' + - id: claude-code-mod-token-weather + name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/token-weather/index.ts' + - id: client-ui-claude-code-mods + name: '@deepseek-ai/dsh-experimental-client-ui-claude-code-mods' diff --git a/apps/cli/package.json b/apps/cli/package.json index e98a7c41ce..4b2368d6cf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -102,7 +102,9 @@ "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-auto-review": "workspace:*" + "@deepseek-ai/dsh-experimental-auto-review": "workspace:*", + "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", + "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods": "workspace:*" }, "devDependencies": { "@agentclientprotocol/sdk": "1.4.0", diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index 7f0f80434c..df0acac9f6 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./testing": { "types": "./lib/types/testing.d.ts", "default": "./lib/types/testing.js" diff --git a/packages/experimental/claude-code-mods/src/elements.ts b/packages/experimental/claude-code-mods/src/elements.ts index dc8612e4b9..e97708bd24 100644 --- a/packages/experimental/claude-code-mods/src/elements.ts +++ b/packages/experimental/claude-code-mods/src/elements.ts @@ -6,6 +6,8 @@ * @module */ +import type { SerializedNode } from './types.ts' + /** One element a constructor returns; `props` are the constructor's argument, frozen. */ export interface UiElement { readonly type: 'Box' | 'Text' | 'Button' @@ -52,17 +54,7 @@ export interface UiElements { readonly Button: (props: ButtonProps) => UiElement } -/** A serialized element: callbacks replaced by the action id the host holds for them. */ -export interface SerializedElement { - readonly type: 'Box' | 'Text' | 'Button' - readonly props: Readonly> - readonly children: readonly SerializedNode[] - /** Set on a `Button` whose `onPress` the host holds. */ - readonly actionId?: string -} - -/** A serialized tree node: an element or a run of text. */ -export type SerializedNode = SerializedElement | string +export type { SerializedElement, SerializedNode } from './types.ts' /** What a serialized tree may carry per prop: scalars only. */ function scalarProps(props: Readonly>): Record { diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 82e76b436e..4da3764ebc 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -27,13 +27,12 @@ import { createHostOps, toolCallResultOf } from './host-ops.ts' import type { AgentBinding } from './host-ops.ts' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { SurfaceTable } from './surfaces.ts' -import type { SurfaceSnapshot } from './surfaces.ts' import { createToolNameAliases } from './tool-names.ts' import { messageOf, record, stringify } from './values.ts' import type { ModDefinition, PromptSubmitInput, PromptSubmitResult, SessionEndInput, SessionEndResult, SessionStartInput, SessionStartResult, ToolCallInput, ToolCallResult, TurnCompleteInput, TurnCompleteResult, TurnStartInput, - TurnStartResult, TurnUsage, UiRenderInput, UiRenderResult, + SurfaceSnapshot, TurnStartResult, TurnUsage, UiRenderInput, UiRenderResult, } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -43,8 +42,7 @@ declare module '@deepseek-ai/cordis' { } export type * from './types.ts' -export type { BoxProps, ButtonProps, SerializedElement, SerializedNode, TextProps, UiElement, UiElements, UiNode } from './elements.ts' -export type { SurfaceSnapshot } from './surfaces.ts' +export type { BoxProps, ButtonProps, TextProps, UiElement, UiElements, UiNode } from './elements.ts' export { BAND_COLUMNS } from './surfaces.ts' export { defineMod, type ModConfig, type ModPlugin, type ModSpec } from './define-mod.ts' export { DEFAULT_TOOL_ALIASES } from './tool-names.ts' diff --git a/packages/experimental/claude-code-mods/src/surfaces.ts b/packages/experimental/claude-code-mods/src/surfaces.ts index c983b7668b..7b22a055e6 100644 --- a/packages/experimental/claude-code-mods/src/surfaces.ts +++ b/packages/experimental/claude-code-mods/src/surfaces.ts @@ -7,21 +7,15 @@ */ import { serializeTree, treeProblem } from './elements.ts' -import type { SerializedNode, UiNode } from './elements.ts' +import type { UiNode } from './elements.ts' import { messageOf } from './values.ts' -import type { UiRenderInput } from './types.ts' +import type { SerializedNode, SurfaceSnapshot, UiRenderInput } from './types.ts' + +export type { SurfaceSnapshot } from './types.ts' /** The band's width the host reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ export const BAND_COLUMNS = 120 -/** One drawn generation of a session's band. */ -export interface SurfaceSnapshot { - /** Increases with every redraw; a press names the generation it saw. */ - readonly generation: number - /** The serialized tree, or null when no mod drew. */ - readonly tree: readonly SerializedNode[] | null -} - /** What the table needs from the host. */ export interface SurfaceHost { /** Raise `ui.render` for one session through the mods and return the tree. */ diff --git a/packages/experimental/claude-code-mods/src/types.ts b/packages/experimental/claude-code-mods/src/types.ts index ed286556ac..7a3a0c7521 100644 --- a/packages/experimental/claude-code-mods/src/types.ts +++ b/packages/experimental/claude-code-mods/src/types.ts @@ -112,6 +112,28 @@ export interface ModDefinition { readonly register: ModRegister } +// ---- Drawn surfaces, as a Client receives them ---- + +/** A serialized element: callbacks replaced by the action id the host holds for them. */ +export interface SerializedElement { + readonly type: 'Box' | 'Text' | 'Button' + readonly props: Readonly> + readonly children: readonly SerializedNode[] + /** Set on a `Button` whose `onPress` the host holds. */ + readonly actionId?: string +} + +/** A serialized tree node: an element or a run of text. */ +export type SerializedNode = SerializedElement | string + +/** One drawn generation of a session's band above the prompt. */ +export interface SurfaceSnapshot { + /** Increases with every redraw; a press names the generation it saw. */ + readonly generation: number + /** The serialized tree, or null when no mod drew. */ + readonly tree: readonly SerializedNode[] | null +} + // ---- Event inputs and results ---- /** `ui.render`: the host asks the mods to draw one surface; the first hook that returns a tree draws it. */ diff --git a/packages/experimental/client-ui-claude-code-mods/package.json b/packages/experimental/client-ui-claude-code-mods/package.json new file mode 100644 index 0000000000..ce93eb927f --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/package.json @@ -0,0 +1,89 @@ +{ + "name": "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods", + "description": "Web band above the prompt for Claude Code mods: draws each session's mod tree and sends button presses back to the bridge", + "version": "0.2.0-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-claude-code-mods" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web", + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ] + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/Band.module.css b/packages/experimental/client-ui-claude-code-mods/src/client/Band.module.css new file mode 100644 index 0000000000..0e4552741b --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/client/Band.module.css @@ -0,0 +1,106 @@ +.band { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 10px; + border-radius: 8px; + background: var(--dsw-alias-bg-layer-1, transparent); + color: var(--dsw-alias-label-primary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +.box { + display: flex; + min-width: 0; +} + +.box[data-direction='row'] { + flex-direction: row; + flex-wrap: wrap; + align-items: center; +} + +.box[data-direction='column'] { + flex-direction: column; +} + +.box[data-border] { + border: 1px solid var(--dsw-alias-border-l2, currentColor); + border-radius: 6px; +} + +.text { + display: inline; +} + +.text[data-bold] { + font-weight: 600; +} + +.text[data-dim] { + color: var(--dsw-alias-label-tertiary); +} + +.text[data-italic] { + font-style: italic; +} + +.text[data-underline] { + text-decoration: underline; +} + +/* Claude Code's terminal palette, on this theme's tokens. */ +[data-color='yellow'] { color: var(--dsw-alias-state-warn-primary); } +[data-color='red'] { color: var(--dsw-alias-state-error-primary); } +[data-color='green'] { color: var(--dsw-alias-state-success-primary); } +[data-color='cyan'], +[data-color='blue'] { color: var(--dsw-alias-button-info-fill); } +[data-color='magenta'] { color: var(--dsw-alias-state-business-primary); } +[data-color='gray'] { color: var(--dsw-alias-label-tertiary); } +[data-color='white'], +[data-color='black'] { color: var(--dsw-alias-label-primary); } + +.box[data-border][data-color='yellow'] { border-color: var(--dsw-alias-state-warn-primary); } +.box[data-border][data-color='red'] { border-color: var(--dsw-alias-state-error-primary); } +.box[data-border][data-color='green'] { border-color: var(--dsw-alias-state-success-primary); } +.box[data-border][data-color='cyan'], +.box[data-border][data-color='blue'] { border-color: var(--dsw-alias-button-info-fill); } +.box[data-border][data-color='magenta'] { border-color: var(--dsw-alias-state-business-primary); } + +.button { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0 2px; + padding: 1px 8px; + border: 1px solid var(--dsw-alias-border-l2, currentColor); + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + line-height: 16px; + cursor: pointer; +} + +.button:hover, +.button:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.button:disabled { + cursor: default; + opacity: 0.6; +} + +.hotkey { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.notice { + color: var(--dsw-alias-state-error-primary); + font-size: 11px; +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/Band.tsx b/packages/experimental/client-ui-claude-code-mods/src/client/Band.tsx new file mode 100644 index 0000000000..67809af6fe --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/client/Band.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import type { HostObservable, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SerializedElement, SerializedNode, SurfaceSnapshot } from '@deepseek-ai/dsh-experimental-claude-code-mods/types' +import { NS } from './locales.ts' +import css from './Band.module.css' + +/** What the browser plugin injects: the session's band snapshots and the press that goes back to the Host. */ +export interface BandInjected { + hooks: { band: HostObservable } + /** Run a button of the current drawing on the Host; resolves to the drawing after the press. */ + press: (generation: number, actionId: string) => Promise +} + +/** Full props of the band entry in the input dock. */ +export type BandProps = PropsRuntime<'conversation.input.dock'> + & { press: BandInjected['press']; useBand: (select: (snapshot: SurfaceSnapshot | undefined) => T) => T } + & PropsLocale + +/** The terminal color names Claude Code's `Text` and `Box` accept; the stylesheet maps each to a theme token. */ +const COLORS: ReadonlySet = new Set(['yellow', 'red', 'green', 'cyan', 'blue', 'magenta', 'gray', 'grey', 'white', 'black']) + +function colorOf(value: unknown): string | undefined { + if (typeof value !== 'string' || !COLORS.has(value)) return undefined + return value === 'grey' ? 'gray' : value +} + +function flag(value: unknown): true | undefined { + return value === true ? true : undefined +} + +function textAttributes(props: SerializedElement['props']): Record { + return { + 'data-color': colorOf(props['color']), + 'data-bold': flag(props['bold']), + 'data-dim': flag(props['dimColor']), + 'data-italic': flag(props['italic']), + 'data-underline': flag(props['underline']), + } +} + +function boxStyle(props: SerializedElement['props']): { attributes: Record; style: Record } { + const style: Record = {} + const unit = (value: unknown): string | undefined => typeof value === 'number' ? `${value * 4}px` : undefined + const paddingX = unit(props['paddingX'] ?? props['padding']) + const paddingY = unit(props['paddingY'] ?? props['padding']) + if (paddingX !== undefined) style.paddingLeft = style.paddingRight = paddingX + if (paddingY !== undefined) style.paddingTop = style.paddingBottom = paddingY + const gap = unit(props['gap']) + if (gap !== undefined) style.gap = gap + const bordered = props['border'] !== undefined && props['border'] !== false + return { + attributes: { + 'data-direction': props['flexDirection'] === 'row' ? 'row' : 'column', + 'data-border': bordered ? true : undefined, + 'data-color': bordered ? colorOf(props['borderColor']) : undefined, + }, + style, + } +} + +interface NodeProps { + readonly node: SerializedNode + readonly path: string + readonly generation: number + readonly pending: string | undefined + readonly onPress: (actionId: string) => void + readonly hotkeyLabel: (hotkey: string) => string +} + +function Node({ node, path, generation, pending, onPress, hotkeyLabel }: NodeProps) { + if (typeof node === 'string') return <>{node} + const children = node.children.map((child, index) => ( + + )) + if (node.type === 'Text') return {children} + if (node.type === 'Button') { + const label = String(node.props['label']) + const hotkey = typeof node.props['hotkey'] === 'string' ? node.props['hotkey'] : undefined + // A Button without an action id has no onPress: drawn, never pressable. + const actionId = node.actionId ?? '' + const disabled = node.props['disabled'] === true || actionId === '' || pending !== undefined + return ( + + ) + } + const { attributes, style } = boxStyle(node.props) + return
{children}
+} + +/** + * The band above the prompt: the session's current mod drawing, with buttons + * that run their `onPress` on the Host. Hotkeys are shown as hints; a press is + * a click. + */ +export function Band({ press, useBand, t }: BandProps) { + const snapshot = useBand(value => value) + const [pending, setPending] = useState(undefined) + const [notice, setNotice] = useState(undefined) + if (snapshot === undefined || snapshot.tree === null) return null + const onPress = (actionId: string): void => { + setPending(actionId) + setNotice(undefined) + press(snapshot.generation, actionId).then( + () => { setPending(undefined) }, + (error: unknown) => { + setPending(undefined) + setNotice(t('press.failed', { message: error instanceof Error ? error.message : String(error) })) + }, + ) + } + return ( +
+ {snapshot.tree.map((node, index) => ( + + `[${hotkey}]`} /> + + ))} + {notice === undefined ? null :
{notice}
} +
+ ) +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/index.ts b/packages/experimental/client-ui-claude-code-mods/src/client/index.ts new file mode 100644 index 0000000000..342a47aec8 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/client/index.ts @@ -0,0 +1,18 @@ +/** Browser entry: mounts the bridge's Remote and registers the band above the prompt. */ + +import type { Context } from '@deepseek-ai/cordis' +import modsRemote from '@deepseek-ai/dsh-experimental-claude-code-mods/remote' +import { mountModsBand } from './mount.ts' + +export { inject } from './mount.ts' +export type { BandInjected, BandProps } from './Band.tsx' +export type { ModsBandKey } from './locales.ts' + +/** + * Register the mods band on the Client Context. + * @param ctx - Client Context with the declared `inject` services available. + * @returns the disposer that withdraws the band. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + return await mountModsBand(ctx, modsRemote) +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/locales.ts b/packages/experimental/client-ui-claude-code-mods/src/client/locales.ts new file mode 100644 index 0000000000..67557bc81d --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/client/locales.ts @@ -0,0 +1,23 @@ +/** Web dictionaries for the chrome around a mod's band; the mods' own content is theirs. */ + +/** Locale namespace owned by the mods band. */ +export const NS = 'claude-code-mods' + +/** Simplified Chinese dictionary and key source. */ +export const zh = { + band: 'Claude Code 模组', + pressing: '正在处理…', + 'press.failed': '按钮操作失败:{message}', + 'press.stale': '该按钮属于更早的绘制,面板已刷新', +} satisfies Record + +/** Mods band locale key union. */ +export type ModsBandKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + band: 'Claude Code mods', + pressing: 'Working…', + 'press.failed': 'The button failed: {message}', + 'press.stale': 'That button belonged to an earlier drawing; the band has refreshed', +} satisfies Record diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts b/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts new file mode 100644 index 0000000000..2781b832a3 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts @@ -0,0 +1,122 @@ +/** Source-safe browser registration of the mods band: the Remote stream behind a store, the dock entry, the press. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-experimental-claude-code-mods/remote' +import type { SurfaceSnapshot } from '@deepseek-ai/dsh-experimental-claude-code-mods/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { Band, type BandInjected } from './Band.tsx' +import { en, NS, zh, type ModsBandKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Chrome copy around a mod's band. */ + 'claude-code-mods': ModsBandKey + } +} + +/** Required browser services: the Remote namespace, slots, and localized copy. */ +export const inject = ['remote', 'slots', 'locale'] + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** A minimal observable the slot renderer turns into `useBand`. */ +class BandStore { + private snapshot: SurfaceSnapshot | undefined + private readonly listeners = new Set<() => void>() + + getSnapshot(): SurfaceSnapshot | undefined { + return this.snapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + set(next: SurfaceSnapshot | undefined): void { + this.snapshot = next + for (const listener of [...this.listeners]) listener() + } +} + +/** Keep one session's band store fed from the Host's `watchBand` stream until disposed. */ +function watchSession(ctx: Context, sessionId: SessionId): { store: BandStore; dispose: () => void } { + const store = new BandStore() + const controller = new AbortController() + const handle = ctx.remote.claudeCodeMods.watchBand(sessionId, controller.signal) + void (async () => { + try { + for await (const snapshot of handle) store.set(snapshot) + } catch (error: unknown) { + if (!controller.signal.aborted) ctx.logger.warn(`claude-code-mods band: watch of ${sessionId} ended: ${messageOf(error)}`) + } + })() + return { + store, + dispose: () => { + controller.abort() + handle.dispose() + }, + } +} + +function registerUi(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-claude-code-mods: dictionaries') + const watches = new Map>() + ctx.effect(() => () => { + for (const watch of watches.values()) watch.dispose() + watches.clear() + }, 'client-ui-claude-code-mods: band watches') + const watchOf = (sessionId: SessionId): ReturnType => { + let watch = watches.get(sessionId) + if (watch === undefined) { + watch = watchSession(ctx, sessionId) + watches.set(sessionId, watch) + } + return watch + } + ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ + name: 'conversation.input.dock', + id: 'claude-code-mods', + order: 20, + locale: NS, + inject: (sessionId): BandInjected => ({ + hooks: { band: watchOf(sessionId).store }, + press: async (generation, actionId) => { + const result = await ctx.remote.claudeCodeMods.pressBand(sessionId, generation, actionId) + if (!result.ok) throw result.error + watchOf(sessionId).store.set(result.value) + }, + }), + }, Band)) +} + +/** + * Mount the bridge's Remote contribution and register the band. + * @param ctx - browser Context with `remote`, `slots`, and `locale`. + * @param contribution - the generated `claudeCodeMods` Remote contribution. + * @returns the disposer that withdraws the band and the Remote mount. + */ +export async function mountModsBand(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { + const disposeRemote = await ctx.remote.$mount(contribution) + const ui = ctx.inject(['remote.claudeCodeMods', 'slots', 'locale'], registerUi) + try { + await ui + } catch (error) { + await ui.dispose() + await disposeRemote() + throw error + } + return async () => { + await ui.dispose() + await disposeRemote() + } +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/css-modules.d.ts b/packages/experimental/client-ui-claude-code-mods/src/css-modules.d.ts new file mode 100644 index 0000000000..b63bfbe015 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/css-modules.d.ts @@ -0,0 +1,5 @@ +/** CSS module names emitted by the browser bundle. */ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/experimental/client-ui-claude-code-mods/src/index.ts b/packages/experimental/client-ui-claude-code-mods/src/index.ts new file mode 100644 index 0000000000..4b9d38de35 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/src/index.ts @@ -0,0 +1,4 @@ +/** Host marker for the browser-discovered Claude Code mods band; the browser export draws it. */ + +/** Host plugin body; behavior lives in the client export and the bridge's Remote. */ +export function apply(): void {} diff --git a/packages/experimental/client-ui-claude-code-mods/tests/band.client.spec.tsx b/packages/experimental/client-ui-claude-code-mods/tests/band.client.spec.tsx new file mode 100644 index 0000000000..c807c58771 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/tests/band.client.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +/** The band draws a serialized mod tree and sends a button press to the Host with the generation it saw. */ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SurfaceSnapshot } from '@deepseek-ai/dsh-experimental-claude-code-mods/types' +import { afterEach, expect, it, vi } from 'vitest' +import { Band, type BandProps } from '../src/client/Band.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const WEATHER: SurfaceSnapshot = { + generation: 3, + tree: [{ + type: 'Box', + props: { flexDirection: 'row', paddingX: 1, gap: 1 }, + children: [ + { type: 'Text', props: { color: 'yellow', bold: true }, children: ['☀ Clear'] }, + { type: 'Text', props: {}, children: [' 18% of context'] }, + { type: 'Text', props: { dimColor: true, italic: true, underline: true }, children: [' 36k / 200k'] }, + 'plain tail', + ], + }], +} + +const HOLD: SurfaceSnapshot = { + generation: 4, + tree: [{ + type: 'Box', + props: { flexDirection: 'column', border: true, borderColor: 'yellow', padding: 1 }, + children: [ + { type: 'Text', props: { color: 'nonsense' }, children: ['Blast Radius: rm -rf build'] }, + { type: 'Text', props: { color: 'grey' }, children: ['grey text'] }, + { + type: 'Box', + props: { flexDirection: 'row' }, + children: [ + { type: 'Button', props: { label: 'Proceed', hotkey: '1' }, children: [], actionId: 'a0' }, + { type: 'Button', props: { label: 'Cancel', hotkey: '2' }, children: [], actionId: 'a1' }, + { type: 'Button', props: { label: 'Inert' }, children: [] }, + { type: 'Button', props: { label: 'Off', disabled: true }, children: [], actionId: 'a2' }, + ], + }, + ], + }], +} + +function fixture(initial: SurfaceSnapshot | undefined) { + const state = createSnapshotStore(initial) + const press = vi.fn<(generation: number, actionId: string) => Promise>(() => Promise.resolve()) + const props = { t: makeTranslate(zh), press, useBand: bindSnapshotSelector(state) } as BandProps + render() + return { press, publish(next: SurfaceSnapshot | undefined) { act(() => { state.set(next) }) } } +} + +it('draws nothing without a tree, then the tree as the Host serialized it', () => { + const b = fixture(undefined) + expect(screen.queryByRole('group')).toBeNull() + b.publish({ generation: 1, tree: null }) + expect(screen.queryByRole('group')).toBeNull() + b.publish(WEATHER) + const band = screen.getByRole('group', { name: zh.band }) + expect(band.getAttribute('data-generation')).toBe('3') + expect(band.textContent).toBe('☀ Clear 18% of context 36k / 200kplain tail') + expect(screen.getByText(/Clear/).getAttribute('data-color')).toBe('yellow') + expect(screen.getByText(/Clear/).getAttribute('data-bold')).toBe('true') + const dim = screen.getByText(/36k/) + expect([dim.getAttribute('data-dim'), dim.getAttribute('data-italic'), dim.getAttribute('data-underline')]).toEqual(['true', 'true', 'true']) + expect(dim.getAttribute('data-color')).toBeNull() + const row = band.firstElementChild?.firstElementChild as HTMLElement + expect(row.getAttribute('data-direction')).toBe('row') + expect(row.style.paddingLeft).toBe('4px') + expect(row.style.gap).toBe('4px') +}) + +it('presses a button with the generation it was drawn in and shows the failure of a refused press', async () => { + const b = fixture(HOLD) + const box = screen.getByText(/Blast Radius/).parentElement as HTMLElement + expect([box.getAttribute('data-direction'), box.getAttribute('data-border'), box.getAttribute('data-color')]).toEqual(['column', 'true', 'yellow']) + expect(box.style.paddingTop).toBe('4px') + expect(screen.getByText(/Blast Radius/).getAttribute('data-color')).toBeNull() + expect(screen.getByText('grey text').getAttribute('data-color')).toBe('gray') + const proceed = screen.getByRole('button', { name: /Proceed/ }) + expect(proceed.textContent).toBe('Proceed[1]') + const inert = screen.getByRole('button', { name: 'Inert' }) + const off = screen.getByRole('button', { name: 'Off' }) + expect((inert as HTMLButtonElement).disabled).toBe(true) + expect((off as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(proceed) + expect(b.press).toHaveBeenCalledWith(4, 'a0') + await act(async () => { await Promise.resolve() }) + expect(screen.queryByRole('status')).toBeNull() + + b.press.mockImplementationOnce(() => Promise.reject(new Error('gone'))) + fireEvent.click(screen.getByRole('button', { name: /Cancel/ })) + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('status').textContent).toBe('按钮操作失败:gone') + + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- a press rejected with a bare value is the scenario. + b.press.mockImplementationOnce(() => Promise.reject('text failure')) + fireEvent.click(screen.getByRole('button', { name: /Cancel/ })) + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('status').textContent).toBe('按钮操作失败:text failure') +}) + +it('disables every button while a press is in flight', async () => { + const b = fixture(HOLD) + let settle: (() => void) | undefined + b.press.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + fireEvent.click(screen.getByRole('button', { name: /Proceed/ })) + expect(screen.getByRole('button', { name: /Cancel/ }).disabled).toBe(true) + expect(screen.getByRole('button', { name: /Proceed/ }).getAttribute('aria-busy')).toBe('true') + await act(async () => { settle?.(); await Promise.resolve() }) + expect(screen.getByRole('button', { name: /Cancel/ }).disabled).toBe(false) +}) diff --git a/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts b/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts new file mode 100644 index 0000000000..cc3bd89496 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts @@ -0,0 +1,158 @@ +/** The band's registration: the Remote mount, one watch per session fed into the dock entry, and the press. */ +import { Context, Service } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SurfaceSnapshot } from '@deepseek-ai/dsh-experimental-claude-code-mods/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it, vi } from 'vitest' +import { apply as hostApply } from '../src/index.ts' +import { Band, type BandInjected } from '../src/client/Band.tsx' +import { inject, mountModsBand } from '../src/client/mount.ts' + +const REMOTE: TypertRemoteContribution = { package: '@deepseek-ai/dsh-experimental-claude-code-mods', descriptors: [] } + +/** A fake `watchBand` stream handle the test feeds by hand. */ +function stream() { + const queue: SurfaceSnapshot[] = [] + let wake: (() => void) | undefined + let closed = false + let failure: unknown + const handle = { + disposed: vi.fn(), + push(snapshot: SurfaceSnapshot) { queue.push(snapshot); wake?.() }, + fail(error: unknown) { failure = error; wake?.() }, + close() { closed = true; wake?.() }, + send() {}, + end() {}, + dispose() { handle.disposed(); closed = true; wake?.() }, + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + const next = queue.shift() + if (next !== undefined) { yield next; continue } + if (failure !== undefined) throw failure + if (closed) return + await new Promise((resolve) => { wake = resolve }) + wake = undefined + } + }, + } + return handle +} + +async function fixture() { + const ctx = new Context() + const unmount = vi.fn(async () => {}) + class Remote extends Service { + constructor() { super(ctx, 'remote') } + async $mount(contribution: TypertRemoteContribution) { + expect(contribution).toBe(REMOTE) + return unmount + } + } + new Remote() + const streams = new Map>() + const watchBand = vi.fn((sessionId: SessionId) => { + const handle = stream() + streams.set(sessionId, handle) + return handle + }) + const pressBand = vi.fn(async (_sessionId: SessionId, generation: number, actionId: string) => actionId === 'a0' + ? { ok: true as const, value: { generation: generation + 1, tree: null } satisfies SurfaceSnapshot } + : { ok: false as const, error: new RemoteError('gateway/bad-request', `no action ${actionId}`, {}) }) + ctx.provide('remote.claudeCodeMods', { watchBand, pressBand }) + ctx.provide('locale', new LocaleRuntime(ctx)) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(SlotRegistry) + ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } } } as never, () => null) + let dispose: (() => Promise) | undefined + const fiber = ctx.plugin({ inject: [...inject], apply: async (plugin: Context) => { dispose = await mountModsBand(plugin, REMOTE) } }) + await fiber + const entry = ctx.slots.entries('conversation.input.dock').find(candidate => candidate.component === Band) + if (entry === undefined || dispose === undefined) throw new Error('the band registered no dock entry') + const injected = (sessionId: string): BandInjected => (entry.inject as (sessionId: SessionId) => BandInjected)(sessionId as SessionId) + return { ctx, dispose, unmount, streams, watchBand, pressBand, warn, injected } +} + +it('exposes the host marker and the browser inject list', () => { + hostApply() + expect(inject).toEqual(['remote', 'slots', 'locale']) +}) + +it('watches one stream per session, feeds snapshots into the dock entry, and ends the watches on dispose', async () => { + const f = await fixture() + const first = f.injected('s1') + const again = f.injected('s1') + expect(f.watchBand).toHaveBeenCalledTimes(1) + expect(first.hooks.band).toBe(again.hooks.band) + const seen: (SurfaceSnapshot | undefined)[] = [] + const unsubscribe = first.hooks.band.subscribe(() => { seen.push(first.hooks.band.getSnapshot()) }) + const ignored = first.hooks.band.subscribe(() => { throw new Error('unsubscribed listeners never run') }) + ignored() + expect(first.hooks.band.getSnapshot()).toBeUndefined() + const handle = f.streams.get('s1') + if (handle === undefined) throw new Error('no stream') + handle.push({ generation: 1, tree: null }) + await vi.waitFor(() => { expect(seen).toHaveLength(1) }) + handle.push({ generation: 2, tree: ['drawn'] }) + await vi.waitFor(() => { expect(seen).toHaveLength(2) }) + expect(seen[1]).toEqual({ generation: 2, tree: ['drawn'] }) + + f.injected('s2') + expect(f.watchBand).toHaveBeenCalledTimes(2) + await f.dispose() + expect(handle.disposed).toHaveBeenCalled() + expect(f.streams.get('s2')?.disposed).toHaveBeenCalled() + expect(f.unmount).toHaveBeenCalled() + expect(f.warn).not.toHaveBeenCalled() + unsubscribe() +}) + +it('withdraws the Remote mount when the registration fails', async () => { + const ctx = new Context() + const unmount = vi.fn(async () => {}) + class Remote extends Service { + constructor() { super(ctx, 'remote') } + async $mount() { return unmount } + } + new Remote() + ctx.provide('remote.claudeCodeMods', { watchBand: vi.fn(), pressBand: vi.fn() }) + ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(SlotRegistry) + vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot failed') }) + const fiber = ctx.plugin({ inject: [...inject], apply: (plugin: Context) => mountModsBand(plugin, REMOTE) }) + await expect(fiber).rejects.toThrow('slot failed') + expect(unmount).toHaveBeenCalled() +}) + +it('presses through the Remote, applies the returned drawing, and surfaces a refused press as an error', async () => { + const f = await fixture() + const band = f.injected('s1') + await band.press(2, 'a0') + expect(f.pressBand).toHaveBeenCalledWith('s1', 2, 'a0') + expect(band.hooks.band.getSnapshot()).toEqual({ generation: 3, tree: null }) + await expect(band.press(3, 'a9')).rejects.toThrow('no action a9') + await f.dispose() +}) + +it('logs a stream that fails while watched, but not one ended by disposal', async () => { + const f = await fixture() + f.injected('s1') + const handle = f.streams.get('s1') + if (handle === undefined) throw new Error('no stream') + handle.fail(new Error('connection lost')) + await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s1 ended: connection lost') }) + f.injected('s2') + f.streams.get('s2')?.fail('text failure') + await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s2 ended: text failure') }) + f.injected('s3') + const third = f.streams.get('s3') + if (third === undefined) throw new Error('no stream') + // A failure after disposal is the stream closing, not a report. + const disposing = f.dispose() + third.fail(new Error('closed')) + await disposing + await new Promise(resolve => setTimeout(resolve, 0)) + expect(f.warn).toHaveBeenCalledTimes(2) +}) diff --git a/packages/experimental/client-ui-claude-code-mods/tsconfig.json b/packages/experimental/client-ui-claude-code-mods/tsconfig.json new file mode 100644 index 0000000000..a67175fd53 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/tsconfig.json @@ -0,0 +1,51 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../client/locale/tsconfig.client.json" + }, + { + "path": "../../client/ui-conversation/tsconfig.client.json" + }, + { + "path": "../../client/ui-primitives" + }, + { + "path": "../../client/ui-renderer" + }, + { + "path": "../../client/ui-session" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../core/session" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../claude-code-mods" + } + ] +} diff --git a/packages/experimental/client-ui-claude-code-mods/tsdown.config.ts b/packages/experimental/client-ui-claude-code-mods/tsdown.config.ts new file mode 100644 index 0000000000..9bc22b4332 --- /dev/null +++ b/packages/experimental/client-ui-claude-code-mods/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-experimental-client-ui-claude-code-mods', ['lib/types/index.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ad64c7c9c..24d6457915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,12 @@ importers: '@deepseek-ai/dsh-experimental-auto-review': specifier: workspace:* version: link:../../packages/experimental/auto-review + '@deepseek-ai/dsh-experimental-claude-code-mods': + specifier: workspace:* + version: link:../../packages/experimental/claude-code-mods + '@deepseek-ai/dsh-experimental-client-ui-claude-code-mods': + specifier: workspace:* + version: link:../../packages/experimental/client-ui-claude-code-mods '@deepseek-ai/dsh-experimental-inspector-profile': specifier: workspace:* version: link:../../packages/experimental/inspector-profile @@ -7795,6 +7801,63 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/experimental/client-ui-claude-code-mods: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:* + version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:* + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:* + version: link:../../client/locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:* + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:* + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:* + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:* + version: link:../../client/ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots + '@deepseek-ai/dsh-experimental-claude-code-mods': + specifier: workspace:* + version: link:../claude-code-mods + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/experimental/client-ui-voice-input: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.base.json b/tsconfig.base.json index f2c4877627..368050681a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -50,8 +50,11 @@ "@deepseek-ai/dsh-experimental-speech-to-text/types": ["./packages/experimental/speech-to-text/src/types.ts"], "@deepseek-ai/dsh-experimental-api-speech-to-text/types": ["./packages/experimental/api-speech-to-text/src/types.ts"], "@deepseek-ai/dsh-experimental-client-ui-voice-input/client": ["./packages/experimental/client-ui-voice-input/src/client/index.ts"], + "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods": ["./packages/experimental/client-ui-claude-code-mods/src/index.ts"], + "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods/client": ["./packages/experimental/client-ui-claude-code-mods/src/client/index.ts"], // Claude Code's test-kit module names, served by the mods bridge's test support so the example mods' tests import them unchanged. + "@deepseek-ai/dsh-experimental-claude-code-mods/types": ["./packages/experimental/claude-code-mods/src/types.ts"], "claude-code/testing": ["./packages/experimental/claude-code-mods/tests/support/claude-code-testing.ts"], "claude-code": ["./packages/experimental/claude-code-mods/tests/support/claude-code.ts"], "@deepseek-ai/dsh/profile-boot": ["./apps/cli/src/profile-boot.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 97f4f1b2a6..53a7c72cc2 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -54,6 +54,7 @@ { "path": "./apps/desktop/tsconfig.client.json" }, { "path": "./packages/util/atomic-write" }, { "path": "./packages/experimental/client-ui-voice-input" }, + { "path": "./packages/experimental/client-ui-claude-code-mods" }, { "path": "./packages/client/ui-sidebar-terminal" }, { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, diff --git a/vitest.config.ts b/vitest.config.ts index a018bf09e1..239b69211a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -316,6 +316,9 @@ export default defineConfig({ // The speech entry also imports generated Remote definitions; voice-input.e2e.ts // exercises the built entry, while source tests cover mountVoiceInput. 'packages/experimental/client-ui-voice-input/src/client/index.ts', + // The mods band entry imports the bridge's generated Remote contribution, which exists only in lib; + // the Web snapshot exercises the built entry, while source tests cover mountModsBand. + 'packages/experimental/client-ui-claude-code-mods/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/ui-commands/src/index.ts',