Merge remote-tracking branch 'origin/master' into feat/scrollbar-tokens
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
|
||||
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
|
||||
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
|
||||
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
|
||||
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
|
||||
分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
|
||||
|
||||
|
||||
@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
|
||||
export class SlashController {
|
||||
/** Menu state store (per-session; survives session switches, dies with the scope). */
|
||||
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
|
||||
/**
|
||||
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
|
||||
* 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<SlashSource, () => void>()
|
||||
|
||||
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)
|
||||
for (const src of deps.roster.all()) {
|
||||
src.warm?.(projection)
|
||||
this.watchLexicon(src, projection)
|
||||
}
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +237,23 @@ export class SlashController {
|
||||
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: SlashSource): void {
|
||||
const projection = this.project()
|
||||
source.warm?.(projection)
|
||||
this.watchLexicon(source, projection)
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/** Scope teardown: close and abort (the service deletes the map entry). */
|
||||
@@ -228,6 +262,8 @@ export class SlashController {
|
||||
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). */
|
||||
@@ -248,25 +284,33 @@ export class SlashController {
|
||||
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[]> {
|
||||
/** 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
|
||||
const names = src.lexicon(projection)
|
||||
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-slash] 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])
|
||||
}
|
||||
return rolls
|
||||
this.lexicon.set(rolls)
|
||||
}
|
||||
|
||||
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
|
||||
private watchLexicon(source: SlashSource, 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. */
|
||||
|
||||
@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one trigger source.
|
||||
* 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.
|
||||
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
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
|
||||
|
||||
@@ -165,6 +165,16 @@ export interface SlashSource {
|
||||
* (the render path must stay synchronous and side-effect free).
|
||||
*/
|
||||
lexicon?(session: ClientSessionContext): readonly string[] | undefined
|
||||
/**
|
||||
* Subscribe to changes of this source's {@link SlashSource.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
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ describe('registerSource', () => {
|
||||
slash.registerSource(deferredSource('/', 'beta').source)
|
||||
})
|
||||
|
||||
it('a source registered after controller birth warms in every live controller', async () => {
|
||||
const { slash, mint } = await serviceBench()
|
||||
const ca = slash.sessionOf(mint('a').actx)
|
||||
const cb = slash.sessionOf(mint('b').actx)
|
||||
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
|
||||
slash.registerSource(late.source)
|
||||
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
|
||||
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
|
||||
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('HMR shape: dispose of the registering fiber removes the source', async () => {
|
||||
const { root, slash, mint } = await serviceBench()
|
||||
const controller = slash.sessionOf(mint('a').actx)
|
||||
@@ -513,7 +525,7 @@ describe('lexicon', () => {
|
||||
skill,
|
||||
lexSource('@', 'subagent', ['worker-1']),
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect([...rolls.keys()]).toEqual(['/', '@'])
|
||||
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
||||
expect(rolls.get('@')).toEqual(['worker-1'])
|
||||
@@ -522,7 +534,7 @@ describe('lexicon', () => {
|
||||
|
||||
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
||||
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
||||
expect(controller.lexicon().size).toBe(0)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('two sources on one trigger concatenate in registration order', () => {
|
||||
@@ -531,10 +543,63 @@ describe('lexicon', () => {
|
||||
lexSource('/', 'prompt', ['c']),
|
||||
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
|
||||
expect(rolls.has('@')).toBe(false)
|
||||
})
|
||||
|
||||
it('a source lexicon notification republishes the aggregated store', () => {
|
||||
let roll: readonly string[] | undefined = undefined
|
||||
let notify: (() => void) | undefined
|
||||
const source: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session, listener) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([source])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const seen: number[] = []
|
||||
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
|
||||
roll = ['commit-helper']
|
||||
notify?.()
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
|
||||
expect(seen).toEqual([1])
|
||||
controller.dispose()
|
||||
expect(notify).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
|
||||
const { controller, sources } = controllerBench([])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const warm = vi.fn()
|
||||
const late: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'late',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
lexicon: () => ['fresh'],
|
||||
}
|
||||
sources.push(late)
|
||||
controller.sourceAdded(late)
|
||||
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('a removed source leaves the aggregated lexicon', () => {
|
||||
const src = lexSource('/', 'skill', ['gone'])
|
||||
const { controller, sources } = controllerBench([src])
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
|
||||
sources.splice(sources.indexOf(src), 1)
|
||||
controller.sourceRemoved(src)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbitrate', () => {
|
||||
|
||||
Reference in New Issue
Block a user