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:
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user