mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
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:
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'
|
||||
@@ -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'])
|
||||
Generated
+63
@@ -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':
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user