feat(experimental): draw the mods' AbovePrompt band and expose it over a Remote

The bridge keeps one band per session: ui.render is raised through the
mods in load order, the tree's buttons keep their onPress host-side behind
per-drawing action ids, a $.state read during a render subscribes the band
to that slot, and $.ui.open/close/invalidate, the end of a tool.call chain,
and turn.complete redraw it. ClaudeCodeMods is a TypertRemoteService:
watchBand streams snapshots, pressBand runs a button and redraws. null is
an accepted hook answer (a surface drawn empty).
This commit is contained in:
Tianyi Cui committed 2026-10-03 02:17:21 +08:00
1 parent f1715c67ec
commit 57126d8baf
13 files changed
+615 -25

No files matched your search

@@ -22,13 +22,26 @@
"types": "./lib/types/testing.d.ts",
"default": "./lib/types/testing.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./examples/*": "./examples/*",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
],
"license": "MIT",
"dependencies": {
@@ -50,6 +63,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-user-questions": "workspace:*"
},
"devDependencies": {
@@ -75,6 +89,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-user-questions": "workspace:*"
}
}
@@ -256,7 +256,8 @@ export async function dispatch<E, R>(request: DispatchRequest<E, R>): Promise<R>
// An abandoned hook's later rejection is not an unhandled rejection.
running.catch(() => {})
const result = await Promise.race([running, clock.expired])
if (typeof result !== 'object' || result === null) throw new NoResultError()
// `null` is an answer (a surface drawn empty); only a hook that settles with no object at all is skipped.
if (typeof result !== 'object') throw new NoResultError()
// The hook answered; whatever it started beneath still runs to its end before the event settles.
clock.stop()
if (state.beneath !== undefined) {
@@ -326,7 +327,7 @@ export async function dispatch<E, R>(request: DispatchRequest<E, R>): Promise<R>
.then(() => handler(api, input, next as HookNext<unknown, unknown>) as R | undefined | Promise<R | undefined>)
running.catch(() => {})
const result = await Promise.race([running, clock.expired])
if (typeof result === 'object' && result !== null) return result
if (typeof result === 'object') return result
} catch (error: unknown) {
request.report(`${hook.mod.name}: ${request.event} .catch handler skipped: ${failureOf(error).line}`)
} finally {
@@ -61,6 +61,10 @@ export interface ModsEngineOptions<B> {
readonly catchBudgetMs: number
/** Receives diagnostics: skipped hooks, failed fire-and-forget calls, timer callback errors. */
readonly report: (line: string) => void
/** Observes `$.state` reads, by session key and `<plugin>\u0000<key>` slot, so a drawing can subscribe to what it read. */
readonly onStateRead?: (key: string, slot: string) => void
/** Observes `$.state` writes, by session key and slot. */
readonly onStateWritten?: (key: string, slot: string) => void
}
/** Options for raising one engine event. */
@@ -361,7 +365,9 @@ export class ModsEngine<B> {
switch (op) {
case 'state.get': {
const slot = stateSlot(fields.plugin, fields.key)
return { value: this.state.get(this.options.stateKey(context.binding))?.get(slot) }
const key = this.options.stateKey(context.binding)
this.options.onStateRead?.(key, slot)
return { value: this.state.get(key)?.get(slot) }
}
case 'state.set': {
const slot = stateSlot(fields.plugin, fields.key)
@@ -372,6 +378,7 @@ export class ModsEngine<B> {
this.state.set(key, values)
}
values.set(slot, fields.value)
this.options.onStateWritten?.(key, slot)
return undefined
}
case 'clock.now':
@@ -97,6 +97,8 @@ export interface HostOpsOptions {
readonly modCommands: Set<string>
/** Full names of tools mods registered, whose calls a `tool.call` hook may answer with a successful result. */
readonly modTools: Set<string>
/** Redraw a session's band after a `$.ui` call that changes what it shows; absent when no surface is drawn. */
readonly redraw?: (sessionId: string) => void
}
function requireAgent(context: OpContext<AgentBinding>, op: string): Agent {
@@ -205,6 +207,11 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
return next
}
/** Ask the band of the event's session to redraw, when a surface is drawn at all. */
function redraw(agent: Agent | undefined): void {
if (agent !== undefined) options.redraw?.(agent.session.id)
}
/** One projection's state, or undefined when the registry or the projection's owning plugin is not composed. */
function projection<K extends keyof SessionProjectionStateMap>(session: Session, key: K): SessionProjectionStateMap[K] | undefined {
return ctx.get('sessionProjections')?.stateOf(session, key)
@@ -223,9 +230,13 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
const { text } = record(input)
ctx.logger.info(`${mod.name} (status): ${text === undefined ? '(cleared)' : requireString(text, '$.ui.status text')}`)
},
'ui.invalidate': () => undefined,
'ui.open': () => ({ isPlaced: false, reason: 'this bridge places no panes: the harness Web GUI has no mod pane' }),
'ui.close': () => undefined,
'ui.invalidate': (_input, { binding }) => { redraw(binding.agent) },
// No pane is placed: a mod that degrades to the band (`isPlaced: false`) is drawn there after this call.
'ui.open': (input, { binding }) => {
redraw(binding.agent)
return { id: requireString(record(input).id, '$.ui.open id'), isPlaced: false, reason: 'this host places no panes; draw in AbovePrompt' }
},
'ui.close': (_input, { binding }) => { redraw(binding.agent) },
'ui.panes': () => [],
'ui.ask': async (input, context) => {
const agent = requireAgent(context, 'ui.ask')
@@ -10,11 +10,12 @@
* @module @deepseek-ai/dsh-experimental-claude-code-mods
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { AssistantMessage, ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
@@ -24,13 +25,15 @@ import { RewriteRefusedError } from './chain.ts'
import { ModsEngine } from './engine.ts'
import { createHostOps, toolCallResultOf } from './host-ops.ts'
import type { AgentBinding } from './host-ops.ts'
import { createToolNameAliases } from './tool-names.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,
TurnStartResult, TurnUsage, UiRenderInput, UiRenderResult,
} from './types.ts'
declare module '@deepseek-ai/cordis' {
@@ -40,6 +43,9 @@ 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 { 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'
export { KNOWN_EVENTS } from './matcher.ts'
@@ -59,7 +65,7 @@ export interface Config {
/** Engine events this host raises; a hook on any other known event registers and is reported as unserved. */
export const SERVED_EVENTS: ReadonlySet<string> = new Set([
'session.start', 'session.end', 'prompt.submit', 'turn.start', 'turn.complete', 'tool.call', 'command.run',
'session.start', 'session.end', 'prompt.submit', 'turn.start', 'turn.complete', 'tool.call', 'command.run', 'ui.render',
])
/** Where the deferred argument-rewrite mechanism is specified. */
@@ -182,9 +188,10 @@ function modAnswered(text: string, code: 'MOD_DENIED' | 'MOD_ANSWERED', rendered
/**
* The bridge service: loaded mods, their hooks, and the harness listeners
* that raise their events. Mods join through {@link ClaudeCodeMods.add},
* which {@link defineMod} calls when a mod plugin mounts.
* which {@link defineMod} calls when a mod plugin mounts. The Remote methods
* let a Client draw each session's band above the prompt and press its buttons.
*/
export class ClaudeCodeMods extends Service {
export class ClaudeCodeMods extends TypertRemoteService {
static Config: z<Config> = z.object({
hookTimeoutMs: z.number().default(10_000),
catchTimeoutMs: z.number().default(1_000),
@@ -197,11 +204,11 @@ export class ClaudeCodeMods extends Service {
static inject: string[] = []
private readonly engine: ModsEngine<AgentBinding>
private readonly detached: DetachedRuns
private readonly registrations = new Map<string, Set<() => void>>()
private readonly surfaces: SurfaceTable
constructor(ctx: Context, config: Config) {
super(ctx, 'claudeCodeMods')
super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' })
const hookTimeoutMs = config.hookTimeoutMs ?? 10_000
const catchTimeoutMs = config.catchTimeoutMs ?? 1_000
const processTimeoutMs = config.processTimeoutMs ?? 30_000
@@ -213,19 +220,48 @@ export class ClaudeCodeMods extends Service {
const modCommands = new Set<string>()
const modTools = new Set<string>()
const report = (line: string): void => { ctx.logger.warn(`claude-code-mods: ${line}`) }
const ops = createHostOps({ ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools })
this.engine = new ModsEngine<AgentBinding>({
const agentOf = (sessionId: string): Agent | undefined => ctx.get('agents')?.get(SessionId(sessionId))
const detached = new DetachedRuns(report)
const surfaces = new SurfaceTable({
render: (sessionId, input) => {
const agent = agentOf(sessionId)
if (agent === undefined) return Promise.resolve(null)
return engine.raise<UiRenderInput, UiRenderResult>('ui.render', input, () => null, {
binding: { agent }, signal: detached.controller.signal,
})
},
runAction: async (_sessionId, callback) => {
try {
await callback()
} catch (error: unknown) {
report(`a button's onPress failed: ${messageOf(error)}`)
}
},
report,
})
this.surfaces = surfaces
// Deferred by one macrotask: a mod sets its own state right after the `$` call that triggers the redraw resolves.
const redraw = (sessionId: string): void => {
setTimeout(() => {
if (agentOf(sessionId) !== undefined) void surfaces.refresh(sessionId)
}, 0).unref()
}
const ops = createHostOps({
ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw,
})
const engine: ModsEngine<AgentBinding> = new ModsEngine<AgentBinding>({
ops: op => ops[op],
stateKey: binding => binding.agent?.session.id ?? '',
budgetMs: hookTimeoutMs,
catchBudgetMs: catchTimeoutMs,
report,
onStateRead: (key, slot) => { surfaces.stateRead(key, slot) },
onStateWritten: (key, slot) => { surfaces.stateWritten(key, slot) },
})
const engine = this.engine
this.detached = new DetachedRuns(report)
const detached = this.detached
this.engine = engine
const registrations = this.registrations
ctx.effect(() => async () => {
surfaces.dispose()
for (const owned of registrations.values()) for (const dispose of owned) dispose()
registrations.clear()
await engine.dispose()
@@ -263,6 +299,7 @@ export class ClaudeCodeMods extends Service {
await engine.raise<SessionStartInput, SessionStartResult>(
'session.start', input, e => ({ cwd: e.cwd }), { binding: { agent }, signal: abandon },
)
redraw(agent.session.id)
})
ctx.on('agent/disposed', ({ agent }) => {
@@ -271,6 +308,7 @@ export class ClaudeCodeMods extends Service {
startedTurns.delete(sessionId)
// The agent's scoped registrations unwound with its context; only the bookkeeping remains.
registrations.delete(sessionId)
surfaces.forget(sessionId)
const forget = (): Promise<void> => engine.forgetSession(sessionId)
if (!startedRoots.delete(sessionId)) {
detached.track('session cleanup', forget())
@@ -348,6 +386,7 @@ export class ClaudeCodeMods extends Service {
}
},
})
if (agent !== undefined) redraw(agent.session.id)
if (answer.deny !== undefined) return modAnswered(answer.deny, 'MOD_DENIED', `Error: ${answer.deny}`)
const text = typeof answer.result === 'string' ? answer.result : stringify(answer.result) ?? ''
if (beneath !== undefined) {
@@ -416,10 +455,44 @@ export class ClaudeCodeMods extends Service {
'turn.complete', input, () => ({ text: '' }), { binding: { agent }, signal: detached.controller.signal },
).then((result) => {
if (typeof result.text === 'string' && result.text.length > 0) ctx.logger.info(`claude-code-mods: ${result.text}`)
redraw(session.id)
}))
})
}
/**
* Watch the band above the prompt of one session: the current drawing, then
* every redraw, until the Client stops watching.
* @param agent - the session's agent, resolved by the Gateway.
* @param signal - carrier cancellation.
* @returns the band's snapshots.
*/
@Remote({ mode: 'stream' })
watchBand(agent: Agent, signal: AbortSignal): AsyncIterable<SurfaceSnapshot> {
return this.surfaces.watch(agent.session.id, signal)
}
/**
* Press a button of the band's current drawing: runs the mod's `onPress` and redraws.
* @param agent - the session's agent, resolved by the Gateway.
* @param generation - the drawing the Client saw.
* @param actionId - the button's action id in that drawing.
* @returns the snapshot after the press.
*/
@Remote
pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot> {
return this.surfaces.press(agent.session.id, generation, actionId)
}
/**
* The current drawing of one session's band, drawing it first when nothing was drawn yet.
* @param sessionId - the session.
* @returns the band's snapshot.
*/
band(sessionId: string): Promise<SurfaceSnapshot> {
return this.surfaces.current(sessionId)
}
/**
* Load one mod beneath every mod loaded before it: run its `register`,
* keep its hooks, and report which of its events this host never raises.
@@ -0,0 +1,244 @@
/**
* The band above the prompt as the host draws it for one session: renders
* through the mods' `ui.render` hooks, keeps the latest tree with its button
* callbacks held host-side, redraws when a `$.state` value a render read
* changes or a mod asks, and hands watchers each new generation.
* @module
*/
import { serializeTree, treeProblem } from './elements.ts'
import type { SerializedNode, UiNode } from './elements.ts'
import { messageOf } from './values.ts'
import type { UiRenderInput } 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. */
render(sessionId: string, input: UiRenderInput): Promise<UiNode>
/** Run one button's callback inside the session; the host owns the invocation and its reporting. */
runAction(sessionId: string, callback: () => unknown): Promise<void>
/** Receives diagnostics: invalid trees, failed presses. */
report(line: string): void
}
class SessionBand {
generation = 0
tree: readonly SerializedNode[] | null = null
/** `$.state` slots the last render read, as `<plugin>\u0000<key>`. */
readonly subscribed = new Set<string>()
/** Button callbacks of the current generation, by the `actionId` the serialized tree carries. */
readonly actions = new Map<string, () => unknown>()
readonly watchers = new Set<(snapshot: SurfaceSnapshot) => void>()
/** The render in flight, so concurrent refresh requests share one pass and a request during a pass runs once more after it. */
rendering: Promise<void> | undefined
again = false
/** Set while a render runs: `$.state` reads subscribe. */
collecting: Set<string> | undefined
snapshot(): SurfaceSnapshot {
return { generation: this.generation, tree: this.tree }
}
}
/**
* Bands by session id.
*/
export class SurfaceTable {
private readonly bands = new Map<string, SessionBand>()
private disposed = false
constructor(private readonly host: SurfaceHost) {}
/**
* The latest snapshot of a session's band, drawing it first when nothing was drawn yet.
* @param sessionId - the session.
* @returns the current generation.
*/
async current(sessionId: string): Promise<SurfaceSnapshot> {
const band = this.bandOf(sessionId)
if (band.generation === 0) await this.refresh(sessionId)
return band.snapshot()
}
/**
* Watch a session's band: the current snapshot first, then every redraw, until the signal aborts.
* @param sessionId - the session.
* @param signal - ends the watch.
* @returns the snapshots.
*/
async *watch(sessionId: string, signal: AbortSignal): AsyncIterable<SurfaceSnapshot> {
const band = this.bandOf(sessionId)
const queue: SurfaceSnapshot[] = []
let wake: (() => void) | undefined
const watcher = (snapshot: SurfaceSnapshot): void => {
queue.push(snapshot)
wake?.()
}
band.watchers.add(watcher)
const onAbort = (): void => { wake?.() }
signal.addEventListener('abort', onAbort, { once: true })
try {
const first = await this.current(sessionId)
let delivered = first.generation
yield first
while (!signal.aborted) {
const next = queue.shift()
if (next !== undefined) {
// The first drawing may arrive both as the current snapshot and as a watcher notification.
if (next.generation <= delivered) continue
delivered = next.generation
yield next
continue
}
await new Promise<void>((resolve) => { wake = resolve })
wake = undefined
}
} finally {
band.watchers.delete(watcher)
signal.removeEventListener('abort', onAbort)
}
}
/**
* Redraw a session's band through the mods and notify watchers when the tree changed.
* @param sessionId - the session.
* @returns settles once the redraw landed.
*/
refresh(sessionId: string): Promise<void> {
const band = this.bandOf(sessionId)
if (band.rendering !== undefined) {
band.again = true
return band.rendering
}
band.rendering = this.renderOnce(sessionId, band).finally(() => {
band.rendering = undefined
if (band.again) {
band.again = false
void this.refresh(sessionId)
}
})
return band.rendering
}
/**
* Run the callback a button holds and redraw. A press from an earlier
* generation is ignored: the button it names may no longer exist.
* @param sessionId - the session.
* @param generation - the generation the client saw.
* @param actionId - the id the serialized Button carried.
* @returns the snapshot after the press.
*/
async press(sessionId: string, generation: number, actionId: string): Promise<SurfaceSnapshot> {
const band = this.bandOf(sessionId)
const action = generation === band.generation ? band.actions.get(actionId) : undefined
if (action === undefined) {
this.host.report(`band press ignored: ${actionId} of generation ${generation} is not on the current drawing (${band.generation})`)
return band.snapshot()
}
await this.host.runAction(sessionId, action)
await this.refresh(sessionId)
return band.snapshot()
}
/**
* Note a `$.state` read: during a render it subscribes the band to the slot.
* @param sessionId - the session the state belongs to.
* @param slot - the `<plugin>\u0000<key>` slot.
*/
stateRead(sessionId: string, slot: string): void {
this.bands.get(sessionId)?.collecting?.add(slot)
}
/**
* Note a `$.state` write: a subscribed band redraws.
* @param sessionId - the session the state belongs to.
* @param slot - the `<plugin>\u0000<key>` slot.
*/
stateWritten(sessionId: string, slot: string): void {
const band = this.bands.get(sessionId)
if (band === undefined || !band.subscribed.has(slot)) return
void this.refresh(sessionId)
}
/**
* Forget a session's band.
* @param sessionId - the disposed session.
*/
forget(sessionId: string): void {
const band = this.bands.get(sessionId)
if (band === undefined) return
this.bands.delete(sessionId)
band.watchers.clear()
}
/** Stop drawing: pending watchers end at their next wake. */
dispose(): void {
this.disposed = true
for (const sessionId of [...this.bands.keys()]) this.forget(sessionId)
}
private bandOf(sessionId: string): SessionBand {
let band = this.bands.get(sessionId)
if (band === undefined) {
band = new SessionBand()
this.bands.set(sessionId, band)
}
return band
}
private async renderOnce(sessionId: string, band: SessionBand): Promise<void> {
if (this.disposed) return
const input: UiRenderInput = {
component: 'AbovePrompt',
surface: 'AbovePrompt',
props: { bodyColumns: BAND_COLUMNS, hasSurvey: false, isWorking: false, maxRows: 10 },
viewport: { columns: BAND_COLUMNS },
}
band.collecting = new Set()
let tree: UiNode
try {
tree = await this.host.render(sessionId, input)
} catch (error: unknown) {
this.host.report(`band render failed: ${messageOf(error)}`)
tree = null
}
band.subscribed.clear()
for (const slot of band.collecting) band.subscribed.add(slot)
band.collecting = undefined
const problem = treeProblem(tree)
if (problem !== undefined) {
this.host.report(`a ui.render hook returned a tree that does not validate: ${problem}`)
tree = null
}
// Action ids count up per drawing, so an unchanged tree serializes identically and keeps its generation.
const actions = new Map<string, () => unknown>()
const serialized = serializeTree(tree, (callback) => {
const id = `a${actions.size}`
actions.set(id, callback)
return id
})
const next = serialized.length === 0 ? null : serialized
if (band.generation > 0 && JSON.stringify(next) === JSON.stringify(band.tree)) {
// The same drawing with possibly fresh closures: keep the generation, hold the new callbacks.
band.actions.clear()
for (const [id, action] of actions) band.actions.set(id, action)
return
}
band.generation += 1
band.tree = next
band.actions.clear()
for (const [id, action] of actions) band.actions.set(id, action)
for (const watcher of band.watchers) watcher(band.snapshot())
}
}
@@ -330,6 +330,8 @@ export interface PaneOpenArgs {
/** `$.ui.open` result: this bridge places no panes. */
export interface PaneOpenResult {
/** The pane id the mod chose. */
id: string
isPlaced: boolean
reason?: string
}
@@ -137,6 +137,13 @@ function toolResult(agent: Agent): { isError: boolean; text: string } | undefine
return { isError: message.isError === true, text: message.content.map(block => block.type === 'text' ? block.text : '').join('') }
}
/** A mock model whose route advertises a context window, so the token meter can report pressure against it. */
class WindowedAdapter extends MockAdapter {
override async resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return { ...await super.resolveModel(provider, model), context: { contextWindow: 1000 } }
}
}
function echoTool(name: string, ran: string[] = []) {
return defineContentToolFixture({
name, description: 'echoes its command', parameters: { command: { type: 'string' } },
@@ -777,11 +784,6 @@ describe('turn.complete reasons and sessions the bridge did not follow from the
describe('$.session.usage with the token meter', () => {
it('reports the provider-measured prompt size against the route\'s context window', async () => {
class WindowedAdapter extends MockAdapter {
override async resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return { ...await super.resolveModel(provider, model), context: { contextWindow: 1000 } }
}
}
const mod = writeMod('usage-mod', `
export function register(on) {
on('turn.complete', async ($, e, next) => {
@@ -820,6 +822,9 @@ describe('loading diagnostics and configuration', () => {
.rejects.toThrow(/another mod of that name is already loaded/)
// A hook on an event this host never raises registers, and the load reports it.
const one = await h.ctx.claudeCodeMods.add({ name: 'one-unserved', register: (on) => { on('turn.step', () => ({})) } })
expect(h.warn).toHaveBeenCalledWith('claude-code-mods: one-unserved: on("turn.step") registered, but this host never raises that event')
await one()
const dispose = await h.ctx.claudeCodeMods.add({
name: 'unserved-mod',
register: (on) => {
@@ -954,3 +959,91 @@ describe('a mod\'s $.tool.call reaches the mods loaded before it', () => {
expect(lines).toEqual(['observer-mod: saw echo from caller-mod/user id=mod'])
})
})
describe('the band above the prompt', () => {
it('draws Token Weather from session facts, redraws on the state it read, and resolves a Blast Radius hold by a press', { timeout: 30_000 }, async () => {
const tokenWeather = (await import('../examples/token-weather/index.ts')).default
const blastRadius = (await import('../examples/blast-radius/index.ts')).default
// Loaded outermost: draws a button that throws once armed, to show a failing onPress is reported, not fatal.
const boom = writeMod('boom-mod', `
let armed = false
export function register(on) {
on('session.start', async ($, e, next) => { await $.command.register({ name: 'arm-boom', description: 'arm' }); return next(e) })
on('command.run', { command: 'arm-boom' }, async ($) => { armed = true; $.ui.invalidate('ui.render'); return { text: 'armed' } })
on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
if (!armed) return next(e)
const { Button } = $.ui.resolve(e)
return Button({ label: 'Boom', onPress: () => { armed = false; throw new Error('nope') } })
})
}
`)
const ran: string[] = []
const adapter = new WindowedAdapter([
textResponse('first answer'),
toolCallResponse('c1', 'bash', { command: 'git reset --hard' }), textResponse('held'),
toolCallResponse('c2', 'bash', { command: 'git clean -fd' }), textResponse('ran'),
])
// One band instance, consulted in load order: the guard that yields when idle goes before the readout that always draws.
const h = await harness([boom, blastRadius, tokenWeather], adapter, {
services: async (ctx, workspace) => {
await ctx.plugin(TokenMeter)
await ctx.plugin(LocalFileSystem, { cwd: workspace })
await ctx.plugin(LocalSubprocessRuntime)
},
})
h.ctx.tools.register(echoTool('bash', ran))
const agent = await h.agent()
const mods = h.ctx.claudeCodeMods
// Before a turn, Token Weather has no reading and Blast Radius holds nothing: the band is empty; so is an unknown session's.
expect((await mods.band(agent.session.id)).tree).toBeNull()
expect((await mods.band('no-such-session')).tree).toBeNull()
const seen: unknown[] = []
const watching = new AbortController()
const watcher = (async () => {
for await (const snapshot of mods.watchBand(agent, watching.signal)) seen.push(snapshot)
})()
await h.turn(agent, 'hello')
// turn.complete wrote the reading Token Weather's render reads: the band redraws without an invalidate.
await waitFor(() => JSON.stringify(seen.at(-1)).includes('of context'))
expect(h.warn).not.toHaveBeenCalledWith(expect.stringMatching(/ui.render hook skipped/))
// A risky command holds; the hook polls with real sleeps while the band offers Proceed and Cancel.
const holding = h.turn(agent, 'reset it')
await waitFor(() => JSON.stringify(seen.at(-1)).includes('Cancel'))
const drawn = seen.at(-1) as { generation: number; tree: unknown }
const buttonId = (tree: unknown, label: string): string | undefined =>
[...JSON.stringify(tree).matchAll(/"label":"(\w+)","hotkey":"\d"\},"children":\[\],"actionId":"(a\d+)"/gu)]
.find(match => match[1] === label)?.[2]
const cancelId = buttonId(drawn.tree, 'Cancel')
expect(cancelId).toBeDefined()
await mods.pressBand(agent, drawn.generation, cancelId ?? '')
await holding
expect(ran).toEqual([])
expect(toolResult(agent)?.text).toMatch(/Blast Radius held this command: the user pressed Cancel/)
await waitFor(() => !JSON.stringify(seen.at(-1)).includes('Cancel'))
// Proceed lets the next risky command run.
const proceeding = h.turn(agent, 'clean it')
await waitFor(() => JSON.stringify(seen.at(-1)).includes('Proceed'))
const second = seen.at(-1) as { generation: number; tree: unknown }
const proceedId = buttonId(second.tree, 'Proceed')
await mods.pressBand(agent, second.generation, proceedId ?? '')
await proceeding
expect(ran).toEqual(['git clean -fd'])
// A stale press is ignored and reported, not applied.
await mods.pressBand(agent, second.generation, proceedId ?? '')
expect(h.warn).toHaveBeenCalledWith(expect.stringMatching(/band press ignored/))
// A button whose onPress throws is reported and the band redraws.
await h.ctx.commands.execute(agent, '/arm-boom', [], new AbortController().signal)
await waitFor(() => JSON.stringify(seen.at(-1)).includes('Boom'))
const armed = seen.at(-1) as { generation: number; tree: unknown }
const boomId = [...JSON.stringify(armed.tree).matchAll(/"actionId":"(a\d+)"/gu)][0]?.[1]
await mods.pressBand(agent, armed.generation, boomId ?? '')
expect(h.warn).toHaveBeenCalledWith("claude-code-mods: a button's onPress failed: nope")
await waitFor(() => !JSON.stringify(seen.at(-1)).includes('Boom'))
watching.abort()
await watcher
})
})
@@ -187,6 +187,17 @@ describe('dispatch: one run beneath per hook', () => {
expect(result).toEqual({ ok: '{"command":"original"}' })
expect(report).toHaveBeenCalledWith('a: tool.call hook skipped: rewrote the arguments of echo; rewrites are not served')
expect(core).toHaveBeenCalledTimes(1)
// A validator that throws a bare value is reported through its text.
const bare = await dispatch(request<{ command: string }, { ok: string }>({
input: { command: 'original' },
core,
hooks: [hook(a, 'tool.call', (_$, e, next) => next({ ...(e as object), command: 'other' }))],
report,
validateNext: () => { throw 'refused as text' },
}))
expect(bare).toEqual({ ok: '{"command":"original"}' })
expect(report).toHaveBeenCalledWith('a: tool.call hook skipped: threw Error: refused as text')
})
})
@@ -112,6 +112,11 @@ describe('services a deployment did not compose', () => {
await expect(call('fs.read', { path: 'x' })).rejects.toThrow(/dsh-fs/)
await expect(call('process.run', { argv: ['ls'] })).rejects.toThrow(/dsh-subprocess/)
await expect(call('session.turns', {}, agent)).rejects.toThrow(/turnBoundary projection/)
// Without a drawn surface the ui calls that would redraw the band are plain no-ops.
await expect(call('ui.invalidate', {}, agent)).resolves.toBeUndefined()
await expect(call('ui.invalidate', {})).resolves.toBeUndefined()
await expect(call('ui.open', { id: 'p' }, agent)).resolves.toMatchObject({ id: 'p', isPlaced: false })
await expect(call('ui.close', { id: 'p' }, agent)).resolves.toBeUndefined()
expect(await call('session.usage', {}, agent)).toEqual({ startedAt: 1, context: { window: 0 }, rateLimits: [] })
})
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import { uiElements } from '../src/elements.ts'
import { SurfaceTable } from '../src/surfaces.ts'
import type { SurfaceHost, SurfaceSnapshot } from '../src/surfaces.ts'
const { Box, Text, Button } = uiElements()
function table(render: SurfaceHost['render'], runAction?: SurfaceHost['runAction']) {
const report = vi.fn()
const surfaces = new SurfaceTable({
render,
runAction: runAction ?? (async (_session, callback) => { await callback() }),
report,
})
return { surfaces, report }
}
describe('SurfaceTable', () => {
it('draws on first read, serializes buttons with per-drawing action ids, and keeps the generation for an unchanged tree', async () => {
let count = 0
const pressed: string[] = []
const { surfaces } = table(() => Promise.resolve(Box({ children: [
Text({ children: `count ${count}` }),
Button({ label: 'More', hotkey: '1', onPress: () => { pressed.push('more'); count += 1 } }),
] })))
const first = await surfaces.current('s1')
expect(first).toEqual({ generation: 1, tree: [{
type: 'Box', props: {}, children: [
{ type: 'Text', props: {}, children: ['count 0'] },
{ type: 'Button', props: { label: 'More', hotkey: '1' }, children: [], actionId: 'a0' },
],
}] })
await surfaces.refresh('s1')
expect((await surfaces.current('s1')).generation).toBe(1)
const after = await surfaces.press('s1', 1, 'a0')
expect(pressed).toEqual(['more'])
expect(after.generation).toBe(2)
expect(JSON.stringify(after.tree)).toContain('count 1')
})
it('ignores a press from an earlier generation or an unknown action and reports it', async () => {
const { surfaces, report } = table(() => Promise.resolve(Button({ label: 'Go', onPress: () => {} })))
await surfaces.current('s1')
expect((await surfaces.press('s1', 0, 'a0')).generation).toBe(1)
expect((await surfaces.press('s1', 1, 'a9')).generation).toBe(1)
expect(report).toHaveBeenCalledWith('band press ignored: a0 of generation 0 is not on the current drawing (1)')
expect(report).toHaveBeenCalledWith('band press ignored: a9 of generation 1 is not on the current drawing (1)')
})
it('redraws when a state slot the drawing read is written, not for other slots, and on invalidation', async () => {
let value = 'a'
const { surfaces } = table((sessionId) => {
surfaces.stateRead(sessionId, 'mod\u0000value')
return Promise.resolve(Text({ children: value }))
})
const seen: SurfaceSnapshot[] = []
const controller = new AbortController()
const watching = (async () => {
for await (const snapshot of surfaces.watch('s1', controller.signal)) seen.push(snapshot)
})()
await vi.waitFor(() => { expect(seen).toHaveLength(1) })
value = 'b'
surfaces.stateWritten('s1', 'mod\u0000other')
await new Promise(resolve => setTimeout(resolve, 10))
expect(seen).toHaveLength(1)
surfaces.stateWritten('s1', 'mod\u0000value')
await vi.waitFor(() => { expect(seen).toHaveLength(2) })
expect(seen[1]?.tree).toEqual([{ type: 'Text', props: {}, children: ['b'] }])
surfaces.stateWritten('s2', 'mod\u0000value')
controller.abort()
await watching
expect(seen).toHaveLength(2)
})
it('coalesces overlapping refreshes into one more pass, turns a failed or invalid render into an empty band, and forgets sessions', async () => {
let renders = 0
let mode: 'slow' | 'throw' | 'invalid' = 'slow'
const { surfaces, report } = table(async () => {
renders += 1
if (mode === 'throw') throw new Error('render broke')
if (mode === 'invalid') return { type: 'Text', props: {} } as never
await new Promise(resolve => setTimeout(resolve, 10))
return Text({ children: `render ${renders}` })
})
const a = surfaces.refresh('s1')
const b = surfaces.refresh('s1')
const c = surfaces.refresh('s1')
expect(b).toBe(a)
await Promise.all([a, b, c])
await vi.waitFor(() => { expect(renders).toBe(2) })
mode = 'throw'
await surfaces.refresh('s1')
expect((await surfaces.current('s1')).tree).toBeNull()
expect(report).toHaveBeenCalledWith('band render failed: render broke')
mode = 'invalid'
await surfaces.refresh('s1')
expect(report).toHaveBeenCalledWith('a ui.render hook returned a tree that does not validate: a tree node is object, not an element, text, or a list of them')
surfaces.forget('s1')
surfaces.forget('s1')
surfaces.dispose()
await surfaces.refresh('s1')
expect((await surfaces.current('s1')).generation).toBe(0)
})
it('reports a failing onPress through the host and still redraws', async () => {
const report = vi.fn()
const surfaces = new SurfaceTable({
render: () => Promise.resolve(Button({ label: 'Boom', onPress: () => { throw new Error('nope') } })),
runAction: async (_session, callback) => {
try {
await callback()
} catch (error) {
report(`a button's onPress failed: ${(error as Error).message}`)
}
},
report,
})
await surfaces.current('s1')
await surfaces.press('s1', 1, 'a0')
expect(report).toHaveBeenCalledWith("a button's onPress failed: nope")
})
})
@@ -58,6 +58,9 @@
},
{
"path": "../../util/values"
},
{
"path": "../../typert/protocol"
}
]
}
+3
View File
@@ -7731,6 +7731,9 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:*
version: link:../../core/tools
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
'@deepseek-ai/dsh-user-questions':
specifier: workspace:*
version: link:../../interaction/user-questions