feat: slash system / input service / agent scope
This commit is contained in:
83
packages/client/ui-slash/src/client/MenuView.module.css
Normal file
83
packages/client/ui-slash/src/client/MenuView.module.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* Trigger candidate menu (figma SLASH 39:26572 MenuDropdown): menu surface,
|
||||
* r12, hairline border, shadow-lv3, 4px inset padding; anchored to the
|
||||
* composer top edge, left-aligned with the input text. Cells follow
|
||||
* .Menu_cell (min-h 40, r10, pad 10/8, gap 8, 14/22 primary label) with a
|
||||
* trailing dimmed description. */
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 260px;
|
||||
max-width: 537px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item.active {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.itemName {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemDescription {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Pending-source row: same cell metrics, dimmed label. */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
66
packages/client/ui-slash/src/client/MenuView.tsx
Normal file
66
packages/client/ui-slash/src/client/MenuView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Trigger candidate menu: renders the SlashService menu store into the
|
||||
* conversation.input.overlay anchor. Closed state renders null (the overlay
|
||||
* slot stays mounted); groups render in roster order, pending groups as a
|
||||
* loading row; pointer picks route back through the service (combobox
|
||||
* pattern — focus never leaves the textarea, so rows are mousedown-handled
|
||||
* and the highlight is exposed via aria-activedescendant on the listbox).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './MenuView.module.css'
|
||||
import type { MenuViewInjected } from './slots.ts'
|
||||
|
||||
/** DOM id of one option row (the aria-activedescendant target). */
|
||||
function optionId(source: string, index: number): string {
|
||||
return `dsh-slash-option-${source}-${index}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the candidate menu overlay entry.
|
||||
* @param props - injected face: the menu store and the pick route.
|
||||
* @returns the dropdown while open; null while closed.
|
||||
*/
|
||||
export function MenuView({ menu, onPick }: MenuViewInjected) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => menu.subscribe(fn),
|
||||
() => menu.getSnapshot(),
|
||||
)
|
||||
if (!state.open) return null
|
||||
const { highlight } = state
|
||||
return (
|
||||
<div
|
||||
className={css.menu}
|
||||
role="listbox"
|
||||
aria-label="Trigger suggestions"
|
||||
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
|
||||
>
|
||||
{state.groups.map(group => group.status === 'pending'
|
||||
? <div key={group.source} className={css.loading} data-source={group.source}>Loading {group.source}…</div>
|
||||
: group.items.map((item, index) => {
|
||||
const active = highlight !== null && highlight.source === group.source && highlight.index === index
|
||||
return (
|
||||
<button
|
||||
key={`${group.source}:${item.name}`}
|
||||
id={optionId(group.source, index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={clsx(css.item, active && css.active)}
|
||||
// mousedown, not click: the textarea keeps focus (combobox
|
||||
// pattern) — preventing default stops the focus steal, and the
|
||||
// pick runs before any blur-driven teardown.
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault()
|
||||
onPick(group.source, index)
|
||||
}}
|
||||
>
|
||||
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
|
||||
<span className={css.itemName}>{item.name}</span>
|
||||
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
|
||||
</button>
|
||||
)
|
||||
}))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
packages/client/ui-slash/src/client/contract.ts
Normal file
17
packages/client/ui-slash/src/client/contract.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Frozen service contract of the slash pipeline. Types only. The
|
||||
* SlashService implementation publishes this face as `ctx.slash`; sources
|
||||
* see registerSource alone, the conversation wiring layer resolves its
|
||||
* per-session controller through sessionOf.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashSource } from '../types.ts'
|
||||
import type { SlashController } from './controller.ts'
|
||||
|
||||
/** The `ctx.slash` service face. */
|
||||
export interface SlashServiceContract {
|
||||
/** Register one trigger source; effect disposer. Duplicate (trigger, name) throws. */
|
||||
registerSource(src: SlashSource): () => void
|
||||
/** Resolve the per-session controller for one session scope (lazy; dies with the scope). */
|
||||
sessionOf(actx: ClientContext): SlashController
|
||||
}
|
||||
303
packages/client/ui-slash/src/client/controller.ts
Normal file
303
packages/client/ui-slash/src/client/controller.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* SlashController: the per-session half of the trigger pipeline. Owns every
|
||||
* piece of mutable interaction state — the authoritative trigger hit (span
|
||||
* included; it outlives menu close for space adjudication), the menu store,
|
||||
* and the candidate-fetch lifecycle — and executes pick outcomes by
|
||||
* dispatching the scoped input-mutation events. The root SlashService keeps
|
||||
* only the source roster. One controller per session scope; the service
|
||||
* disposes it with the scope fiber.
|
||||
*/
|
||||
import type { ClientContext, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { detectTrigger } from '../core/detect.ts'
|
||||
import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts'
|
||||
import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, SlashSource, TriggerChar, TriggerGuard,
|
||||
} from '../types.ts'
|
||||
|
||||
/** Roster access the controller borrows from the root service (registration order preserved). */
|
||||
export interface SourceRoster {
|
||||
sources(trigger: string): readonly SlashSource[]
|
||||
all(): readonly SlashSource[]
|
||||
}
|
||||
|
||||
/** Construction seams of one controller. */
|
||||
export interface SlashControllerDeps {
|
||||
/** The owning session scope (event dispatch + teardown registration site). */
|
||||
actx: ClientContext
|
||||
/** The session's stable host identity (the projection handed to sources). */
|
||||
sessionId: SessionId
|
||||
/** Root-service roster view. */
|
||||
roster: SourceRoster
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session trigger pipeline state and orchestration. All mutation stays
|
||||
* inside; MenuView renders from {@link SlashController.menu} and routes
|
||||
* pointer picks back through {@link SlashController.pick}.
|
||||
*/
|
||||
export class SlashController {
|
||||
/** Menu state store (per-session; survives session switches, dies with the scope). */
|
||||
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
|
||||
|
||||
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
|
||||
private hit: TriggerHit | null = null
|
||||
private fetch: AbortController | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly deps: SlashControllerDeps) {
|
||||
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
|
||||
// roster warm here replaces the projection-transition watch — there are
|
||||
// no capability steps to react to.
|
||||
const projection = this.project()
|
||||
for (const src of deps.roster.all()) src.warm?.(projection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a draft/caret change through trigger detection and drive the menu.
|
||||
* @param draft - full draft text.
|
||||
* @param caret - caret offset into `draft`.
|
||||
* @param guard - availability tier derived from the input phase.
|
||||
* @param draftRev - the input machine's current draft revision, stamped
|
||||
* into the hit span for pick-time CAS.
|
||||
*/
|
||||
track(draft: string, caret: number, guard: TriggerGuard, draftRev: number): void {
|
||||
if (this.disposed) return
|
||||
const raw = detectTrigger(draft, caret, guard)
|
||||
if (raw === null) {
|
||||
this.hit = null
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
return
|
||||
}
|
||||
const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } }
|
||||
const prev = this.menu.getSnapshot()
|
||||
const same = prev.open && prev.hit !== null
|
||||
&& prev.hit.trigger === hit.trigger && prev.hit.query === hit.query
|
||||
&& prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end
|
||||
this.hit = hit
|
||||
if (same) return
|
||||
const roster = this.deps.roster.sources(hit.trigger)
|
||||
if (roster.length === 0) {
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
return
|
||||
}
|
||||
if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) {
|
||||
this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name)))
|
||||
}
|
||||
this.reduce({ type: 'hit', hit })
|
||||
this.fetchCandidates(hit, roster)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer pick from MenuView: route the clicked candidate through onPick
|
||||
* and execute claim/insert outcomes via the scoped input events.
|
||||
* @param source - source (group) name.
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
pick(source: string, index: number): void {
|
||||
const state = this.menu.getSnapshot()
|
||||
const hit = this.hit
|
||||
if (this.disposed || !state.open || hit === null) return
|
||||
const group = state.groups.find(g => g.source === source)
|
||||
const candidate = group !== undefined && group.status === 'ready' ? group.items[index] : undefined
|
||||
if (candidate === undefined) return
|
||||
const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source)
|
||||
if (src === undefined) return
|
||||
const outcome = src.onPick({
|
||||
candidate,
|
||||
session: this.project(),
|
||||
position: hit.position,
|
||||
via: 'menu',
|
||||
span: hit.span,
|
||||
})
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
this.execute(outcome, hit.span)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard arbitration while the menu is open.
|
||||
* @param key - intercepted key.
|
||||
* @param composing - inside IME composition: everything passes.
|
||||
* @returns consumed / pick-highlighted / pass.
|
||||
*/
|
||||
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome {
|
||||
if (composing || this.disposed) return 'pass'
|
||||
const state = this.menu.getSnapshot()
|
||||
if (!state.open) return 'pass'
|
||||
switch (key) {
|
||||
case 'up': {
|
||||
this.reduce({ type: 'move', dir: -1 })
|
||||
return 'consumed'
|
||||
}
|
||||
case 'down': {
|
||||
this.reduce({ type: 'move', dir: 1 })
|
||||
return 'consumed'
|
||||
}
|
||||
case 'escape': {
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
return 'consumed'
|
||||
}
|
||||
case 'enter': {
|
||||
if (state.highlight === null) return 'pass'
|
||||
this.pick(state.highlight.source, state.highlight.index)
|
||||
return 'pick-highlighted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Space adjudication over the just-completed leading token: polls sources'
|
||||
* matchSpace (hot state, synchronous) and dispatches the outcome itself.
|
||||
* @returns true when a claim/insert was actually applied by the input —
|
||||
* the caller preventDefaults exactly then.
|
||||
*/
|
||||
onSpace(): boolean {
|
||||
const hit = this.hit
|
||||
if (this.disposed || hit === null || hit.position !== 'leading') return false
|
||||
const token = hit.trigger + hit.query
|
||||
const projection = this.project()
|
||||
for (const src of this.deps.roster.sources(hit.trigger)) {
|
||||
if (src.matchSpace === undefined) continue
|
||||
const outcome = src.matchSpace(projection, token)
|
||||
if (outcome === undefined) continue
|
||||
if (outcome === 'handled') return true
|
||||
return this.execute(outcome, hit.span)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one reference occurrence to its model form via the owning
|
||||
* source's codec (design §9.1 prompt serialization: registry → explicit
|
||||
* call → await). Owner missing or codec-less rejects — the submit attempt
|
||||
* blocks instead of silently downgrading to the clipboard text.
|
||||
* @param source - owning source name.
|
||||
* @param ref - owner-scoped reference id.
|
||||
* @param signal - the submit attempt's abort signal.
|
||||
* @returns the model representation (e.g. `<skill>name</skill>`).
|
||||
*/
|
||||
serializeReference(source: string, ref: string, signal: AbortSignal): Promise<string> {
|
||||
const owner = this.deps.roster.all().find(s => s.name === source)
|
||||
if (owner?.codec === undefined) {
|
||||
return Promise.reject(new Error(`slash: no serializer for reference source "${source}"`))
|
||||
}
|
||||
return owner.codec.serialize(ref, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter last adjudication: polls sources' matchEnter in registration
|
||||
* order, first non-undefined wins. The outcome returns to the caller (the
|
||||
* input machine applies it inside the same submit attempt — no event).
|
||||
* @param line - trimmed draft; the leading char selects the trigger roster.
|
||||
* @param signal - attempt-scoped abort from the input machine.
|
||||
* @returns the winning outcome or undefined (default sink). Rejects when a
|
||||
* polled source's warmup fails — the caller must not silently downgrade.
|
||||
*/
|
||||
async adjudicate(line: string, signal: AbortSignal): Promise<PickOutcome> {
|
||||
const projection = this.project()
|
||||
for (const src of this.deps.roster.all()) {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error ? signal.reason : new Error('slash adjudication aborted')
|
||||
}
|
||||
if (src.matchEnter === undefined || !line.startsWith(src.trigger)) continue
|
||||
const outcome = await src.matchEnter(projection, line, signal)
|
||||
if (outcome !== undefined) return outcome
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Drop the menu group of a disposed source (root registry change notification). */
|
||||
sourceRemoved(source: SlashSource): void {
|
||||
const state = this.menu.getSnapshot()
|
||||
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
|
||||
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
|
||||
}
|
||||
}
|
||||
|
||||
/** Scope teardown: close and abort (the service deletes the map entry). */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
this.hit = null
|
||||
}
|
||||
|
||||
/** The session projection handed to sources (agent-backed identity; constant per scope). */
|
||||
private project(): ClientSessionContext {
|
||||
return { sessionId: this.deps.sessionId }
|
||||
}
|
||||
|
||||
/** Execute a claim/insert/text outcome via the scoped input events (actx as dispatch subject); true = the input applied it. */
|
||||
private execute(outcome: PickOutcome, span: import('../types.ts').TokenSpan): boolean {
|
||||
const { actx } = this.deps
|
||||
if (outcome === undefined || outcome === 'handled') return false
|
||||
if ('claim' in outcome) {
|
||||
return actx.bail(actx, 'slash/input-begin-command', { claim: outcome.claim, span }) === true
|
||||
}
|
||||
if ('text' in outcome) {
|
||||
return actx.bail(actx, 'slash/input-insert-text', { text: outcome.text, span }) === true
|
||||
}
|
||||
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the sources' plain-text reference lexicons (decision 21),
|
||||
* grouped by trigger: sources implementing the hook are polled with the
|
||||
* session projection (onSpace's poll pattern); undefined answers (roll not
|
||||
* hot yet) are skipped; multiple sources on one trigger concatenate in
|
||||
* registration order.
|
||||
* @returns trigger → decorated-name roll for the decoration scan.
|
||||
*/
|
||||
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
|
||||
const projection = this.project()
|
||||
const rolls = new Map<TriggerChar, readonly string[]>()
|
||||
for (const src of this.deps.roster.all()) {
|
||||
if (src.lexicon === undefined) continue
|
||||
const names = src.lexicon(projection)
|
||||
if (names === undefined) continue
|
||||
const prev = rolls.get(src.trigger)
|
||||
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
|
||||
}
|
||||
return rolls
|
||||
}
|
||||
|
||||
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
|
||||
private fetchCandidates(hit: TriggerHit, roster: readonly SlashSource[]): void {
|
||||
this.stopFetch()
|
||||
const controller = new AbortController()
|
||||
this.fetch = controller
|
||||
const generation = this.menu.getSnapshot().generation
|
||||
const projection = this.project()
|
||||
for (const source of roster) {
|
||||
void source
|
||||
.candidates(projection, { query: hit.query, position: hit.position, signal: controller.signal })
|
||||
.then(
|
||||
(items) => {
|
||||
if (controller.signal.aborted) return
|
||||
this.reduce({ type: 'source-settled', generation, source: source.name, items })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
console.error(`[ui-slash] source "${source.name}" candidates failed:`, error)
|
||||
this.reduce({ type: 'source-failed', generation, source: source.name })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private stopFetch(): void {
|
||||
this.fetch?.abort()
|
||||
this.fetch = null
|
||||
}
|
||||
|
||||
private reduce(ev: MenuEvent): void {
|
||||
const cur = this.menu.getSnapshot()
|
||||
const next = menuReduce(cur, ev)
|
||||
if (next !== cur) this.menu.set(next)
|
||||
}
|
||||
}
|
||||
65
packages/client/ui-slash/src/client/index.ts
Normal file
65
packages/client/ui-slash/src/client/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Slash trigger plugin, browser half: the SlashService (`ctx.slash`) owning
|
||||
* trigger detection, the candidate menu, and the pick pipeline; MenuView
|
||||
* self-registers into the conversation.input.overlay slot. Frozen pipeline
|
||||
* contract in ./contract.ts; sources register through ctx.slash alone.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from './service.ts'
|
||||
import type { MenuViewInjected } from './slots.ts'
|
||||
import { MenuView } from './MenuView.tsx'
|
||||
|
||||
export { SlashService } from './service.ts'
|
||||
export { SlashController } from './controller.ts'
|
||||
export type { SlashControllerDeps, SourceRoster } from './controller.ts'
|
||||
export type { MenuViewInjected } from './slots.ts'
|
||||
export type {
|
||||
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext,
|
||||
CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec,
|
||||
ReferenceInsert, SlashCandidate, SlashPick, SlashSource, SubmitOutcome, TokenSpan,
|
||||
TriggerChar, TriggerGuard, TriggerPosition,
|
||||
} from '../types.ts'
|
||||
export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts'
|
||||
export type { SlashServiceContract } from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
slash: SlashService
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: controller resolution reads the session scope tree. */
|
||||
export const inject = ['sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register MenuView into the
|
||||
* input overlay once its declarer is up.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(SlashService)
|
||||
// Conditional mount: 'conversation.input.overlay' is declared by the
|
||||
// conversation composer entry, and the conversation service is mounted
|
||||
// after that declaration lands on the ledger — its presence is the
|
||||
// registration-safe signal (same seam as toolview registrants).
|
||||
ctx.inject(['slots', 'conversation', 'slash', 'sessions'], (scope: ClientContext) => {
|
||||
const slash = scope.slash
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'slash-menu',
|
||||
order: 0,
|
||||
inject: (sessionId): MenuViewInjected => {
|
||||
// Session-scoped slot: resolve this session's controller (the slot
|
||||
// frame hands ids, not ctx — the registered id→ctx interchange).
|
||||
const actx = sessions.scope(sessionId as Parameters<typeof sessions.scope>[0])
|
||||
if (actx === undefined) throw new Error(`ui-slash: session "${String(sessionId)}" resolved no scope`)
|
||||
const controller = slash.sessionOf(actx)
|
||||
return {
|
||||
menu: controller.menu,
|
||||
onPick: (source, index) => { controller.pick(source, index) },
|
||||
}
|
||||
},
|
||||
}, MenuView), 'ui-slash: MenuView overlay registration')
|
||||
})
|
||||
}
|
||||
96
packages/client/ui-slash/src/client/service.ts
Normal file
96
packages/client/ui-slash/src/client/service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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, SessionId, SessionsService } 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.
|
||||
* @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)
|
||||
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),
|
||||
all: () => live.sources,
|
||||
},
|
||||
})
|
||||
live.controllers.set(id, controller)
|
||||
actx.effect(() => () => {
|
||||
controller.dispose()
|
||||
live.controllers.delete(id)
|
||||
}, 'slash: session controller')
|
||||
return controller
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
38
packages/client/ui-slash/src/client/slots.ts
Normal file
38
packages/client/ui-slash/src/client/slots.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Overlay-slot contract surface of the slash plugin. The
|
||||
* 'conversation.input.overlay' slot is OWNED by the ui-conversation composer
|
||||
* entry (declaring is claiming: anchor, children declaration, lifecycle),
|
||||
* but the SlotMap type merge lives here: the owner package depends on this
|
||||
* one, so the dependency direction admits no reverse type import, and a
|
||||
* type-erased registration is ruled out (PR #632 review). The owner's
|
||||
* program picks this merge up transitively through its ui-slash imports.
|
||||
*/
|
||||
// Type-only edge: the SlotMap augmentation below merges into this package's interface.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MenuState } from '../core/contract.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* The InputBar floating overlay anchor: MenuView (this package) and the
|
||||
* popupSelect shell (ui-command) contribute list entries; each reads its
|
||||
* own store and renders null while closed. Declared (children table) by
|
||||
* ui-conversation's composer entry; the anchor hides with the input
|
||||
* under a takeover.
|
||||
*/
|
||||
'conversation.input.overlay': { kind: 'list'; scope: 'session' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Injected business face of the MenuView overlay entry. */
|
||||
export interface MenuViewInjected {
|
||||
/** The service's menu state store (read-only here; MenuView subscribes). */
|
||||
menu: SnapshotStore<MenuState>
|
||||
/**
|
||||
* Pointer pick routed back through the service pipeline.
|
||||
* @param source - source (group) name.
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
onPick(source: string, index: number): void
|
||||
}
|
||||
Reference in New Issue
Block a user