refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,108 @@
/* 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: min(260px, 100%);
/* 537 is the design cap; the 100% clamp keeps the menu inside the composer
card when a narrow viewport shrinks the card below the cap (the overlay
anchor is exactly the card's width). */
max-width: min(537px, 100%);
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in MenuView.tsx). */
max-height: 320px;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
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);
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.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);
}
/* Heading row above a source group: non-interactive small grey text,
* padding aligned with items (mirrors ui-primitives Menu .label). */
.groupTitle {
padding: 8px 10px;
font-size: 12px;
line-height: 16px;
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);
}

View File

@@ -0,0 +1,116 @@
/**
* Trigger candidate menu: renders the InputTriggerService menu store into the
* conversation.input.overlay anchor. Closed state renders null (the overlay
* slot stays mounted); groups render in roster order under localized title
* rows, 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 { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './MenuView.module.css'
import type { MenuViewInjected } from './slots.ts'
import type { MenuKey } from './locales.ts'
/** Full menu props: injected face + the locale seat. */
export type MenuViewProps = MenuViewInjected & PropsLocale<'slash.menu'>
/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */
const MAX_HEIGHT = 320
/** 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); `t` rides the standard locale seat.
* @returns the dropdown while open; null while closed.
*/
export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) {
const state = useSyncExternalStore(
fn => menu.subscribe(fn),
() => menu.getSnapshot(),
)
const listRef = useRef<HTMLDivElement>(null)
// The list is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update (the anchor moves
// when the composer grows).
const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state)
const highlight = state.open ? state.highlight : null
// Focus stays in the textarea (combobox pattern), so the browser never
// scrolls the active option into view on keyboard moves — do it here.
useEffect(() => {
if (highlight === null) return
document.getElementById(optionId(highlight.source, highlight.index))
?.scrollIntoView({ block: 'nearest' })
}, [highlight])
// Dismiss on pointer outside the menu AND outside the composer card
// (clicking the textarea or bottom bar must not close the menu).
useEffect(() => {
if (!state.open) return
const onPointerDown = (ev: PointerEvent): void => {
if (!(ev.target instanceof Node)) return
if (listRef.current?.contains(ev.target)) return
const composerCard = listRef.current?.closest('[data-composer-card]')
if (composerCard?.contains(ev.target)) return
onDismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, onDismiss])
if (!state.open) return null
return (
<div
ref={listRef}
className={css.menu}
style={{ maxHeight }}
role="listbox"
aria-label={t('suggestions.aria')}
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
>
<div className={css.viewport}>
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
? null
: (
<Fragment key={group.source}>
{/* Source names key the dictionary open-endedly: the lookup chain
returns an unknown key verbatim, so an unregistered source
shows its raw name — hence the cast past the typed key union. */}
<div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source as MenuKey)}</div>
{group.status === 'pending'
? <div className={css.loading} data-source={group.source}>{t('loading')}</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>
)
})}
</Fragment>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
/**
* Frozen service contract of the slash pipeline. Types only. The
* InputTriggerService implementation publishes this face as `ctx.inputTriggers`; 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 { InputTriggerSource } from '../types.ts'
import type { InputTriggerController } from './controller.ts'
/** The `ctx.inputTriggers` service face. */
export interface InputTriggerServiceContract {
/** Register one trigger source; effect disposer. Duplicate (trigger, name) throws. */
registerSource(src: InputTriggerSource): () => void
/** Resolve the per-session controller for one session scope (lazy; dies with the scope). */
sessionOf(actx: ClientContext): InputTriggerController
}

View File

@@ -0,0 +1,398 @@
/**
* InputTriggerController: 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 InputTriggerService 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, InputTriggerSource, TriggerChar, TriggerGuard,
} from '../types.ts'
/** Roster access the controller borrows from the root service (registration order preserved). */
export interface SourceRoster {
sources(trigger: string): readonly InputTriggerSource[]
all(): readonly InputTriggerSource[]
}
/** Construction hooks for one controller. */
export interface InputTriggerControllerDeps {
/** 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 InputTriggerController.menu} and routes
* pointer picks back through {@link InputTriggerController.pick}.
*/
export class InputTriggerController {
/** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Name of the source opened through the programmatic launcher, or null for
* trigger-detected/closed menus. Composer chrome subscribes to this store
* for the launcher's expanded state without owning a second menu model.
*/
readonly launcher: SnapshotStore<string | null> = createSnapshotStore<string | null>(null)
/**
* Aggregated hot reference lexicon, grouped by trigger (plain-text-reference decision;
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* sources implementing the lexicon hook are polled with the session
* projection; undefined answers (roll not hot yet) are skipped; multiple
* sources on one trigger concatenate in registration order. A snapshot
* store because rolls change asynchronously (catalog settles, children
* spawn/exit) — render-side consumers subscribe instead of re-reading a
* mutable answer.
*/
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
/** 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
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
private readonly lexiconOffs = new Map<InputTriggerSource, () => void>()
constructor(private readonly deps: InputTriggerControllerDeps) {
// 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)
this.watchLexicon(src, projection)
}
this.refreshLexicon()
}
/**
* 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 launched = this.launcher.getSnapshot() !== null
this.clearLauncher()
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 = !launched && 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 (launched || !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)
}
/**
* Toggle a menu containing exactly one registered source. The supplied hit
* is a synthetic selection span rather than a typed trigger token, but
* picks deliberately reuse the ordinary source callback and scoped input
* mutation pipeline.
* @param source - registered source name under `hit.trigger`.
* @param hit - synthetic hit carrying position and pick-time draft CAS.
*/
toggleSource(source: string, hit: TriggerHit): void {
if (this.disposed) return
if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) {
this.dismiss()
return
}
const match = this.deps.roster.sources(hit.trigger).find(item => item.name === source)
if (match === undefined) {
this.dismiss()
return
}
this.stopFetch()
this.hit = hit
this.launcher.set(source)
this.menu.set(seedGroups(this.menu.getSnapshot(), [source]))
this.reduce({ type: 'hit', hit })
this.fetchCandidates(hit, [match])
}
/**
* 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 (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).
* @param source - the source whose registration was disposed.
*/
sourceRemoved(source: InputTriggerSource): 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 })
}
this.lexiconOffs.get(source)?.()
this.lexiconOffs.delete(source)
this.refreshLexicon()
}
/**
* Admit a source registered after this controller's birth (root registry
* change notification): warm it and fold its roll into the live lexicon —
* the constructor-time prewarm covers only the roster present at scope
* birth.
* @param source - the newly registered source.
*/
sourceAdded(source: InputTriggerSource): void {
const projection = this.project()
source.warm?.(projection)
this.watchLexicon(source, projection)
this.refreshLexicon()
}
/** External dismiss (e.g. pointer outside the composer area). */
dismiss(): void {
if (this.disposed) return
this.stopFetch()
this.reduce({ type: 'close' })
}
/** 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
for (const off of this.lexiconOffs.values()) off()
this.lexiconOffs.clear()
}
/** 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
}
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
private refreshLexicon(): void {
const projection = this.project()
const rolls = new Map<TriggerChar, readonly string[]>()
for (const src of this.deps.roster.all()) {
if (src.lexicon === undefined) continue
let names: readonly string[] | undefined
try {
names = src.lexicon(projection)
} catch (error) {
// A faulty source drops silently with a console record (the
// candidate-fetch failure policy); the refresh runs inside
// notification callbacks, where a throw would starve other consumers.
console.error(`[ui-input-trigger] source "${src.name}" lexicon failed:`, error)
continue
}
if (names === undefined) continue
const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
}
this.lexicon.set(rolls)
}
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: InputTriggerSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
}
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
private fetchCandidates(hit: TriggerHit, roster: readonly InputTriggerSource[]): 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-input-trigger] 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 clearLauncher(): void {
if (this.launcher.getSnapshot() !== null) this.launcher.set(null)
}
private reduce(ev: MenuEvent): void {
const cur = this.menu.getSnapshot()
const next = menuReduce(cur, ev)
if (next !== cur) this.menu.set(next)
if (!next.open) this.clearLauncher()
}
}

View File

@@ -0,0 +1,80 @@
/**
* Slash trigger plugin, browser half: the InputTriggerService (`ctx.inputTriggers`) 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.inputTriggers alone.
*/
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { InputTriggerService } from './service.ts'
import type { MenuViewInjected } from './slots.ts'
import { MenuView } from './MenuView.tsx'
import { en, zh, type MenuKey } from './locales.ts'
export { InputTriggerService } from './service.ts'
export { InputTriggerController } from './controller.ts'
export type { InputTriggerControllerDeps, SourceRoster } from './controller.ts'
export type { MenuViewInjected } from './slots.ts'
export type { MenuViewProps } from './MenuView.tsx'
export type { MenuKey } from './locales.ts'
export type {
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext,
CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec,
ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitOutcome, TokenSpan,
TriggerChar, TriggerGuard, TriggerPosition,
} from '../types.ts'
export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts'
export type { InputTriggerServiceContract } from './contract.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** The outward face only; the concrete service stays inside this plugin. */
inputTriggers: import('./contract.ts').InputTriggerServiceContract
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The candidate menu's copy: group titles keyed by source name, the pending row, and the listbox aria. */
'slash.menu': MenuKey
}
}
/** Namespace owning the candidate-menu copy. */
const MENU_NS = 'slash.menu'
/** Required services: controller resolution reads the session scope tree; the menu copy is localized. */
export const inject = ['sessions', 'locale']
/**
* 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(InputTriggerService)
ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries')
ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => {
const inputTriggers = scope.inputTriggers
const sessions = scope.sessions
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'slash-menu',
order: 0,
locale: MENU_NS,
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)
if (actx === undefined) throw new Error(`ui-input-trigger: session "${String(sessionId)}" resolved no scope`)
const controller = inputTriggers.sessionOf(actx)
return {
menu: controller.menu,
onPick: (source, index) => { controller.pick(source, index) },
onDismiss: () => { controller.dismiss() },
}
},
}, MenuView))
})
}

View File

@@ -0,0 +1,26 @@
/**
* `slash.menu` namespace dictionaries: group titles keyed by source name
* (the lookup chain returns the key itself, so an unknown source shows its
* raw name), the pending row, and the listbox aria label.
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command': '命令',
'skill': '技能',
'subagent': '子智能体',
'loading': '正在加载…',
'suggestions.aria': '触发候选建议',
} satisfies Record<string, string>
/** The slash.menu namespace key union. */
export type MenuKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command': 'Commands',
'skill': 'Skills',
'subagent': 'Subagents',
'loading': 'Loading…',
'suggestions.aria': 'Trigger suggestions',
} satisfies Record<MenuKey, string>

View File

@@ -0,0 +1,107 @@
/**
* InputTriggerService (`ctx.inputTriggers`): 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 InputTriggerController}; the service only registers sources, resolves
* controllers by session scope, and relays roster changes.
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InputTriggerSource } from '../types.ts'
import { InputTriggerController } from './controller.ts'
import type { InputTriggerServiceContract } 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: InputTriggerSource[]
/** Per-session controllers; entries are deleted by their scope disposer. */
readonly controllers: Map<SessionId, InputTriggerController>
}
/** The `ctx.inputTriggers` trigger pipeline service (root registry + controller resolution). */
export class InputTriggerService extends Service implements InputTriggerServiceContract {
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, 'inputTriggers')
}
/**
* 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: InputTriggerSource): () => 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-input-trigger] 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): InputTriggerController {
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 InputTriggerController({
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-input-trigger: sessions service unavailable')
return sessions
}
}

View File

@@ -0,0 +1,40 @@
/**
* 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. The owner's
* program picks this merge up transitively through its ui-input-trigger 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-commands) 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 (copy rides the standard locale seat, not this face). */
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
/** Dismiss the menu (external pointer outside the composer area). */
onDismiss: () => void
}

View File

@@ -0,0 +1,57 @@
/**
* Frozen pure-core contract: trigger detection and
* menu reduction, zero React / DOM / cordis. Types only — implementations
* live in sibling modules annotated with these
* aliases; the service shell wires them to ctx.
*/
import type { InputTriggerCandidate, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition } from '../types.ts'
/** A detected trigger token under the caret. */
export interface TriggerHit {
readonly trigger: TriggerChar
/** Text between the trigger char and the caret, live-filtered. */
readonly query: string
/** leading = draft trimmed (whitespace incl. newlines) starts with the token. */
readonly position: TriggerPosition
/** Token span; draftRev injected by the caller. */
readonly span: TokenSpan
}
/**
* Detect a trigger token at the caret under the given guard tier.
* Word-boundary rule: the char before the trigger is start-of-line,
* whitespace, or punctuation; `user@host` and URL '/' do not trigger.
* Returns null when no trigger is live at the caret.
*/
export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null
/** Menu state: one group per source; empty ready groups auto-close the menu. */
export interface MenuState {
readonly open: boolean
readonly hit: TriggerHit | null
/** Monotonic per-hit generation; stale source settlements are dropped. */
readonly generation: number
readonly groups: readonly {
readonly source: string
readonly status: 'pending' | 'ready'
readonly items: readonly InputTriggerCandidate[]
}[]
readonly highlight: { readonly source: string; readonly index: number } | null
}
/** Menu reduction events. Source failure = silent group removal (log only; no error UI tier). */
export type MenuEvent =
| { readonly type: 'hit'; readonly hit: TriggerHit | null }
| { readonly type: 'source-settled'; readonly generation: number; readonly source: string; readonly items?: readonly InputTriggerCandidate[] }
| { readonly type: 'source-failed'; readonly generation: number; readonly source: string }
| { readonly type: 'move'; readonly dir: 1 | -1 }
| { readonly type: 'close' }
/** Pure menu reducer; returns the same reference when the event is stale or a no-op. */
export type MenuReduce = (state: MenuState, ev: MenuEvent) => MenuState
/**
* Exact-name lookup in one source's ready group; null when absent or the
* group is not ready.
*/
export type ExactMatch = (groups: MenuState['groups'], source: string, name: string) => InputTriggerCandidate | null

View File

@@ -0,0 +1,63 @@
/**
* Trigger detection pure core. Scans backward from
* the caret for a live trigger char under the guard tier and applies the
* word-boundary rules. Zero React / DOM / cordis.
*/
import type { TriggerChar } from '../types.ts'
import type { DetectTrigger } from './contract.ts'
const WORD_CHAR = /[\p{L}\p{N}_]/u
const WHITESPACE = /\s/u
/**
* Word-boundary rule: a trigger char opens only at start-of-draft, after
* whitespace (newlines included), or after punctuation. Two URL carve-outs
* keep '/' dead inside URLs (both pinned by tests): '/' after a ':' that
* itself follows a non-whitespace char (scheme separator, `https:/…`), and
* '/' directly after another '/' (second slash of `//`).
*/
function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
if (index === 0) return true
const prev = draft.charAt(index - 1)
if (WHITESPACE.test(prev)) return true
if (WORD_CHAR.test(prev)) return false
if (char === '/') {
if (prev === '/') return false
if (prev === ':' && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false
}
return true
}
/**
* Detect a trigger token at the caret. Scans left from the caret and stops
* at the first whitespace (the token under edit never spans whitespace);
* trigger chars failing the guard tier or the word boundary are treated as
* ordinary token chars and the scan continues (`user@host`, URL slashes).
* Guard tiers: plain = both chars live; claimed = '/' fully suppressed,
* '@' live; frozen = none.
*
* @param draft - Full draft text.
* @param caret - Caret offset into `draft`.
* @param guard - Availability tier derived from the input phase.
* @returns The hit with `query` = trigger-to-caret slice and `span` =
* `{start: triggerIndex, end: caret}`; `span.draftRev` is a placeholder `0`
* — the calling shell stamps the real revision. Null when no trigger is
* live at the caret.
*/
export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
if (guard.tier === 'frozen') return null
for (let i = caret - 1; i >= 0; i--) {
const ch = draft.charAt(i)
if (WHITESPACE.test(ch)) return null
if (ch !== '/' && ch !== '@') continue
if (guard.tier === 'claimed' && ch === '/') continue
if (!boundaryOk(draft, i, ch)) continue
return {
trigger: ch,
query: draft.slice(i + 1, caret),
position: draft.search(/\S/) === i ? 'leading' : 'inline',
span: { start: i, end: caret, draftRev: 0 },
}
}
return null
}

View File

@@ -0,0 +1,140 @@
/**
* Menu reduction pure core. One group per source;
* generation-gated settlement; empty ready groups auto-close. Zero React /
* DOM / cordis. Stale or no-op events return the same state reference so
* store subscribers skip re-renders.
*
* Roster protocol: the frozen `hit` event carries no source roster, so the
* reducer cannot invent groups. Opening from a closed state, the shell seeds
* the roster with {@link seedGroups} and then dispatches `hit`; a `hit`
* while open (query refinement) resets the existing groups to pending under
* a new generation. Auto-close and explicit close drop the groups.
*/
import type { InputTriggerCandidate } from '../types.ts'
import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
/** Closed rest state with generation 0; store initializer and test seed. */
export const MENU_CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
/**
* Replace the group roster with pending groups for `sources`, in order.
* Shell-side step before dispatching `hit` on a fresh menu open.
*
* @param state - Current menu state.
* @param sources - Source names registered for the hit trigger, menu order.
* @returns State carrying the new pending roster; highlight cleared.
*/
export function seedGroups(state: MenuState, sources: readonly string[]): MenuState {
return { ...state, groups: sources.map(source => ({ source, status: 'pending', items: [] })), highlight: null }
}
/** Close, preserving the generation so in-flight settlements stay droppable. */
const closed = (state: MenuState): MenuState =>
state.open || state.hit !== null || state.groups.length > 0 || state.highlight !== null
? { open: false, hit: null, generation: state.generation, groups: [], highlight: null }
: state
/** First item of the first non-empty ready group, or null. */
function firstHighlight(groups: MenuState['groups']): MenuState['highlight'] {
for (const g of groups) {
if (g.status === 'ready' && g.items.length > 0) return { source: g.source, index: 0 }
}
return null
}
/** The highlight itself when it still points at a ready item, else null. */
function validHighlight(highlight: MenuState['highlight'], groups: MenuState['groups']): MenuState['highlight'] {
if (!highlight) return null
const g = groups.find(x => x.source === highlight.source)
return g && g.status === 'ready' && highlight.index < g.items.length ? highlight : null
}
/** Flatten ready items into (source, index) positions in group order. */
function positions(groups: MenuState['groups']): { source: string; index: number }[] {
const out: { source: string; index: number }[] = []
for (const g of groups) {
if (g.status !== 'ready') continue
for (let i = 0; i < g.items.length; i++) out.push({ source: g.source, index: i })
}
return out
}
/** True when every group is ready with zero items (the auto-close condition). */
const allReadyEmpty = (groups: MenuState['groups']): boolean =>
groups.every(g => g.status === 'ready' && g.items.length === 0)
/**
* Pure menu reducer. `hit` opens a new generation over the seeded roster
* (null hit closes); `source-settled` outside the current generation, the
* open menu, or the roster is dropped; a settlement or failure leaving every
* group ready-and-empty (or no groups) auto-closes; `source-failed` silently
* removes the group (the shell logs); `move` cycles the highlight across
* ready items.
*
* @param state - Current menu state.
* @param ev - Menu event.
* @returns Next state; the same reference when stale or a no-op.
*/
export const menuReduce: MenuReduce = (state, ev) => {
switch (ev.type) {
case 'hit': {
if (ev.hit === null) return closed(state)
return {
open: true,
hit: ev.hit,
generation: state.generation + 1,
groups: state.groups.map(g => ({ source: g.source, status: 'pending', items: [] })),
highlight: null,
}
}
case 'source-settled': {
if (!state.open || ev.generation !== state.generation) return state
const idx = state.groups.findIndex(g => g.source === ev.source)
if (idx < 0) return state
const items: readonly InputTriggerCandidate[] = ev.items ?? []
const groups = state.groups.map((g, i) =>
i === idx ? { source: g.source, status: 'ready' as const, items } : g)
if (allReadyEmpty(groups)) return closed(state)
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
return { ...state, groups, highlight }
}
case 'source-failed': {
if (!state.open || ev.generation !== state.generation) return state
if (!state.groups.some(g => g.source === ev.source)) return state
const groups = state.groups.filter(g => g.source !== ev.source)
if (groups.length === 0 || allReadyEmpty(groups)) return closed(state)
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
return { ...state, groups, highlight }
}
case 'move': {
if (!state.open) return state
const pos = positions(state.groups)
if (pos.length === 0) return state
const hl = state.highlight
const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1
const next = pos[at < 0
? (ev.dir === 1 ? 0 : pos.length - 1)
: (at + ev.dir + pos.length) % pos.length]
if (next === undefined) return state
if (hl && next.source === hl.source && next.index === hl.index) return state
return { ...state, highlight: next }
}
case 'close':
return closed(state)
}
}
/**
* Exact-name lookup in one source's ready group.
*
* @param groups - Menu groups.
* @param source - Source (group) name.
* @param name - Candidate name to match exactly.
* @returns The candidate, or null when the group is absent, not ready, or
* has no candidate of that name.
*/
export const exactMatch: ExactMatch = (groups, source, name) => {
const group = groups.find(g => g.source === source)
if (!group || group.status !== 'ready') return null
return group.items.find(c => c.name === name) ?? null
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Slash trigger plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half ships
* via exports["./client"], discovered through the package.json dsh.client
* declaration.
*/
/** Host plugin body — no host-side behavior for the slash trigger plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-input-trigger`.
* @module @deepseek-ai/dsh-client-ui-input-trigger/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-input-trigger'
/** Cordis companion plugin name. */
export const name = 'client-ui-input-trigger-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the trigger pipeline is a browser-side pure core
* (detect/reduce/match) plus a registry whose disposal is proven by the
* HMR-safety spec; it emits no cordis events and owns no cross-plugin
* mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,257 @@
/**
* Frozen cross-package contract for the input trigger pipeline. Types only —
* no runtime code. Sources (ui-commands / ui-skill / ui-subagent) and the
* conversation input layer import from here; changes require main-thread
* arbitration.
*
* Providers receive a {@link ClientSessionContext} projection per call —
* never a Cordis context or the mutable Session. RPC and service access go
* through the provider plugin's own root context captured at registration.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* The provider-facing projection of one client session. It carries stable
* identity alone; a source that calls Agent-bound RPCs must consult its own
* service's capability state because an addressed persisted subagent may
* have a client scope without a live Host Agent.
*/
export interface ClientSessionContext {
readonly sessionId: SessionId
}
/** Trigger character a source binds to. */
export type TriggerChar = '/' | '@'
/** Where the trigger token sits in the draft: leading (trimmed draft starts with it) or inline. */
export type TriggerPosition = 'leading' | 'inline'
/** Which of the three pick paths produced a pick. */
export type PickVia = 'menu' | 'space' | 'enter'
/** One menu candidate. Pure display data — zero behavior declaration. */
export interface InputTriggerCandidate {
readonly name: string
readonly description?: string
readonly icon?: string
readonly hint?: string
}
/** Pick-moment snapshot of the trigger token span. CAS: stale draftRev ⇒ the whole action no-ops. */
export interface TokenSpan {
readonly start: number
readonly end: number
readonly draftRev: number
}
/**
* Command-mode entry credential. Pure data + a closure method — no class, no
* cross-package runtime value (client bundle purity).
*/
export interface CommandClaim {
/** Integrity-watched draft prefix, e.g. `'/goal '` — breaking startsWith releases the claim. */
readonly token: string
/** Ghost-text hint rendered while the claim's args are blank. */
readonly hint?: string
/** Enter transaction, supplied by the source as a closure. */
submit(args: string, actx: ClientContext): Promise<SubmitOutcome>
}
/**
* Inline reference insertion. The draft holds one U+FFFC placeholder per
* occurrence; the owner supplies both user-facing projections at insert time
* (the model representation is serialized on submit via the source codec).
*/
export interface ReferenceInsert {
readonly source: string
readonly ref: string
/** Chip display label (fallback-cached on the occurrence). */
readonly label: string
/** Clipboard / persistence projection, e.g. `/name` (never the model form). */
readonly clipboardText: string
}
/** Settled result of a command submit transaction. */
export interface SubmitOutcome {
readonly kind: 'success' | 'error'
readonly text?: string
}
/**
* Unified pick return. `undefined` = miss → default sink; `'handled'` = the
* source dealt with it internally (e.g. opened its popup shell). The `text`
* arm is the plain-text reference path (decision recorded in
* .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* the token span is
* replaced with literal text — no occurrence identity, no placeholder; any
* chip visual is derived downstream by scanning the draft against the
* source lexicons.
*/
export type PickOutcome =
| { readonly claim: CommandClaim }
| { readonly insert: ReferenceInsert }
| { readonly text: string }
| 'handled'
| undefined
/** Candidate request passed to a source. The signal is superseded on query change / menu close. */
export interface CandidateRequest {
readonly query: string
readonly position: TriggerPosition
readonly signal: AbortSignal
}
/** Everything a source receives on pick: candidate + session projection + the span snapshot for CAS. */
export interface InputTriggerPick {
readonly candidate: InputTriggerCandidate
readonly session: ClientSessionContext
readonly position: TriggerPosition
readonly via: PickVia
readonly span: TokenSpan
}
/**
* Reference codec owned by a source that produces {@link ReferenceInsert}
* outcomes: the clipboard projection for copy/cut/persistence, and the model
* serialization invoked per occurrence by the submit attempt (async, abort
* rides the attempt signal; failure blocks the send — never a silent
* downgrade to the clipboard text).
*/
export interface ReferenceCodec {
/** Clipboard / persistence projection of one reference (e.g. `/name`). */
clipboardText(ref: string): string
/** Model serialization of one reference (e.g. `<skill>name</skill>`). */
serialize(ref: string, signal: AbortSignal): Promise<string>
}
/**
* One trigger source. Every callback receives the session's
* ClientSessionContext projection; sources keep no copy across calls.
*
* Space/enter adjudication rides the optional match hooks: implementing one
* IS the participation claim — the pipeline polls each implementing source
* with the leading token; the first non-undefined answer wins (registration
* order); no claimant → default sink. The hooks split because their timing
* budgets differ: space fires mid-keystroke and must answer synchronously
* from hot state, while enter may await the source's own warmup.
*/
export interface InputTriggerSource {
readonly trigger: TriggerChar
/** Menu group label; unique per trigger — duplicate registration throws. */
readonly name: string
/** Menu group display order (lower = higher in the list; default 0). */
readonly order?: number
candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]>
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
onPick(pick: InputTriggerPick): PickOutcome
/** Synchronous space-time adjudication over hot state only. `token` is the just-completed leading token (e.g. '/goal'). */
matchSpace?(session: ClientSessionContext, token: string): PickOutcome
/**
* Enter-time adjudication; may strong-wait the source's own warmup and
* reject on warmup failure. `line` is the full trimmed draft: the source
* parses it and applies its own kind policy — args-tolerant kinds claim
* with trailing text present, bare-token-only kinds answer undefined
* unless the line is exactly the token.
*/
matchEnter?(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome>
/**
* Scope-birth prewarm hook (fire-and-forget): the per-session controller
* calls it once when the session scope comes alive so sources can fetch
* their backing data before the first interaction.
*/
warm?(session: ClientSessionContext): void
/**
* Synchronous hot-snapshot name roll for plain-text reference decoration.
* Implementing IS the participation claim: the render side
* scans the draft for `<trigger><name>` tokens and decorates exact matches.
* `undefined` = backing data not warm yet — no decoration, never a fetch
* (the render path must stay synchronous and side-effect free).
*/
lexicon?(session: ClientSessionContext): readonly string[] | undefined
/**
* Subscribe to changes of this source's {@link InputTriggerSource.lexicon} answer
* for one session (backing data settled, invalidated, or refreshed). The
* controller re-polls lexicon on each notification; a source whose roll
* never changes after warm omits the hook.
* @param session - stable session projection.
* @param listener - invalidation callback.
* @returns unsubscribe.
*/
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
/** Reference codec; required for sources producing insert outcomes. */
readonly codec?: ReferenceCodec
}
/** Trigger availability tier, derived from the input phase by the wiring layer. */
export interface TriggerGuard {
/** plain: '/' and '@' live; claimed: '/' suppressed, '@' live; frozen: none. */
readonly tier: 'plain' | 'claimed' | 'frozen'
}
/** Keys the menu intercepts while open (all behind the IME composition guard). */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape'
/** consumed = key handled; pick-highlighted = enter picked the highlight; pass = let the input see it. */
export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
/** Request payload of the scoped begin-command input event. */
export interface BeginCommandRequest {
readonly claim: CommandClaim
readonly span: TokenSpan
}
/** Request payload of the scoped insert-reference input event. */
export interface InsertReferenceRequest {
readonly reference: ReferenceInsert
readonly span: TokenSpan
}
/** Request payload of the scoped consume-token input event. */
export interface ConsumeTokenRequest {
readonly guard:
| { readonly kind: 'span'; readonly span: TokenSpan }
| { readonly kind: 'bare-token'; readonly token: string }
}
/** Request payload of the scoped insert-text input event (the plain-text reference path). */
export interface InsertTextRequest {
/** Literal replacement for the trigger token span (e.g. `/name `). */
readonly text: string
readonly span: TokenSpan
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Applies one command claim to the scoped Input. Dispatched with the
* session's scope carrier; the owning session's input listener returns
* `true` only after the phase and span CAS checks pass and the machine
* actually mutated — producers treat anything else as "not applied".
* @param request - Claim and menu-time span CAS.
* @mode bail
*/
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
/**
* Inserts one reference into the scoped Input (same carrier routing and
* applied-truth contract as begin-command).
* @param request - Reference and menu-time span CAS.
* @mode bail
*/
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
/**
* Consumes one command token after business success (popup settle /
* menu-pick execute). Same carrier routing and applied-truth contract.
* @param request - Exact span or bare-token guard.
* @mode bail
*/
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
/**
* Replaces the trigger token span with literal text — the plain-text
* reference path. Same carrier routing and applied-truth
* contract; the draft gains ordinary characters, no occurrence entry.
* @param request - Replacement text and menu-time span CAS.
* @mode bail
*/
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
}
}