Files
deepseek-harness/packages/client/ui-slash/src/client/service.ts
Yif 45c9205caf feat(client): localize composer hints and rework input command interaction
Unify the /plan claimed hint with the plan placeholder through a locale
namespace, localize slash menu group titles, replace the PermissionSelect
native select with the Menu primitive, add a goal pause verb chain, clamp
anchored popups to the viewport with scroll-into-view and outside-dismiss,
and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
2026-07-29 19:46:48 +08:00

108 lines
4.2 KiB
TypeScript

/**
* SlashService (`ctx.slash`): the root half of the trigger pipeline — the
* stateless source registry plus the per-session controller map. Every piece
* of mutable interaction state (hit, menu, fetch) lives on the
* {@link SlashController}; the service only registers sources, resolves
* controllers by session scope, and relays roster changes.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '../types.ts'
import { SlashController } from './controller.ts'
import type { SlashServiceContract } from './contract.ts'
/**
* All mutable service state in one holder: cordis service methods run behind
* the caller-ctx tracker, so mutation goes through one property read — never
* field assignment on `this`.
*/
interface LiveState {
/** Registration order = menu group order = matchSpace/matchEnter poll order. */
readonly sources: SlashSource[]
/** Per-session controllers; entries are deleted by their scope disposer. */
readonly controllers: Map<SessionId, SlashController>
}
/** The `ctx.slash` trigger pipeline service (root registry + controller resolution). */
export class SlashService extends Service implements SlashServiceContract {
static inject = ['sessions']
private readonly live: LiveState = { sources: [], controllers: new Map() }
/**
* @param ctx - owning root context (the service registers itself as `slash`).
*/
constructor(ctx: Context) {
super(ctx, 'slash')
}
/**
* Register one trigger source. Live session controllers are notified so a
* source arriving after scope birth still warms and joins the lexicon.
* @param src - the source; (trigger, name) must be unique — duplicates throw.
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
* while a controller shows the source's menu group drops that group.
*/
registerSource(src: SlashSource): () => void {
const { live } = this
if (live.sources.some(s => s.trigger === src.trigger && s.name === src.name)) {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
}
live.sources.push(src)
for (const controller of live.controllers.values()) {
try {
controller.sourceAdded(src)
} catch (error) {
// Contain faulty source callbacks (warm/subscribeLexicon): the
// registration must stand with a usable disposer and the remaining
// controllers must still be notified.
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
}
}
return () => {
const at = live.sources.indexOf(src)
if (at < 0) return
live.sources.splice(at, 1)
for (const controller of live.controllers.values()) controller.sourceRemoved(src)
}
}
/**
* Resolve the per-session controller for one session scope (lazy; the
* scope disposer removes and disposes it). Construction warms the source
* roster once — sessions are always agent-backed, so scope birth is the
* single prewarm moment.
* @param actx - session-scope ctx.
* @returns the resident controller.
*/
sessionOf(actx: ClientContext): SlashController {
const sessions = this.sessions()
const id = sessions.scopeOf(actx)
if (id === undefined) throw new Error('slash.sessionOf requires a session scope')
const { live } = this
const existing = live.controllers.get(id)
if (existing !== undefined) return existing
const controller = new SlashController({
actx,
sessionId: id,
roster: {
sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
all: () => live.sources,
},
})
live.controllers.set(id, controller)
actx.effect(() => () => {
controller.dispose()
live.controllers.delete(id)
}, 'slash: session controller')
return controller
}
private sessions(): ISessions {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
return sessions
}
}