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.
This commit is contained in:
Tianyi Cui committed 2026-10-03 02:46:33 +08:00
1 parent 57126d8baf
commit 0b87f74cd3
23 files changed
+953 -26

No files matched your search

@@ -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'
+3 -1
View File
@@ -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",
@@ -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"
@@ -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<Record<string, string | number | boolean>>
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<string, unknown>>): Record<string, string | number | boolean> {
@@ -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'
@@ -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. */
@@ -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<Record<string, string | number | boolean>>
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. */
@@ -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"
}
}
@@ -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;
}
@@ -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<SurfaceSnapshot | undefined> }
/** Run a button of the current drawing on the Host; resolves to the drawing after the press. */
press: (generation: number, actionId: string) => Promise<void>
}
/** Full props of the band entry in the input dock. */
export type BandProps = PropsRuntime<'conversation.input.dock'>
& { press: BandInjected['press']; useBand: <T>(select: (snapshot: SurfaceSnapshot | undefined) => T) => T }
& PropsLocale<typeof NS>
/** The terminal color names Claude Code's `Text` and `Box` accept; the stylesheet maps each to a theme token. */
const COLORS: ReadonlySet<string> = 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<string, string | true | undefined> {
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<string, string | true | undefined>; style: Record<string, string> } {
const style: Record<string, string> = {}
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) => (
<Node key={`${path}.${index}`} node={child} path={`${path}.${index}`} generation={generation} pending={pending} onPress={onPress} hotkeyLabel={hotkeyLabel} />
))
if (node.type === 'Text') return <span className={css.text} {...textAttributes(node.props)}>{children}</span>
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 (
<button
type="button"
className={css.button}
disabled={disabled}
aria-busy={pending === actionId}
onClick={() => { onPress(actionId) }}
>
{label}
{hotkey === undefined ? null : <span className={css.hotkey}>{hotkeyLabel(hotkey)}</span>}
</button>
)
}
const { attributes, style } = boxStyle(node.props)
return <div className={css.box} style={style} {...attributes}>{children}</div>
}
/**
* 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<string | undefined>(undefined)
const [notice, setNotice] = useState<string | undefined>(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 (
<div className={css.band} role="group" aria-label={t('band')} data-generation={snapshot.generation}>
{snapshot.tree.map((node, index) => (
<span key={`${snapshot.generation}.${index}`} className={css.text}>
<Node node={node} path={String(index)} generation={snapshot.generation} pending={pending} onPress={onPress} hotkeyLabel={hotkey => `[${hotkey}]`} />
</span>
))}
{notice === undefined ? null : <div className={css.notice} role="status">{notice}</div>}
</div>
)
}
@@ -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<void>> {
return await mountModsBand(ctx, modsRemote)
}
@@ -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<string, string>
/** 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<ModsBandKey, string>
@@ -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<SessionId, ReturnType<typeof watchSession>>()
ctx.effect(() => () => {
for (const watch of watches.values()) watch.dispose()
watches.clear()
}, 'client-ui-claude-code-mods: band watches')
const watchOf = (sessionId: SessionId): ReturnType<typeof watchSession> => {
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<void>> {
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()
}
}
@@ -0,0 +1,5 @@
/** CSS module names emitted by the browser bundle. */
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
@@ -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 {}
@@ -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<SurfaceSnapshot | undefined>(initial)
const press = vi.fn<(generation: number, actionId: string) => Promise<void>>(() => Promise.resolve())
const props = { t: makeTranslate(zh), press, useBand: bindSnapshotSelector(state) } as BandProps
render(<Band {...props} />)
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<void>((resolve) => { settle = resolve }))
fireEvent.click(screen.getByRole('button', { name: /Proceed/ }))
expect(screen.getByRole<HTMLButtonElement>('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<HTMLButtonElement>('button', { name: /Cancel/ }).disabled).toBe(false)
})
@@ -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<SurfaceSnapshot> {
while (true) {
const next = queue.shift()
if (next !== undefined) { yield next; continue }
if (failure !== undefined) throw failure
if (closed) return
await new Promise<void>((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<string, ReturnType<typeof stream>>()
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<void>) | 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)
})
@@ -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"
}
]
}
@@ -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'])
+63
View File
@@ -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':
+3
View File
@@ -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"],
+1
View File
@@ -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" },
+3
View File
@@ -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',