Open command menu from composer plus button
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/ui-slash/README.md
|
||||
README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38
|
||||
README.zh.md: 03dac56870de5b083124716825001009b4293736
|
||||
README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2
|
||||
README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
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. 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.
|
||||
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. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. 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.
|
||||
|
||||
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
|
||||
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 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);变更需经主线程仲裁。
|
||||
|
||||
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
|
||||
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
|
||||
|
||||
`/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ 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)
|
||||
/**
|
||||
* 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 (decision 21):
|
||||
* sources implementing the lexicon hook are polled with the session
|
||||
@@ -81,6 +87,8 @@ export class SlashController {
|
||||
*/
|
||||
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
|
||||
@@ -90,7 +98,7 @@ export class SlashController {
|
||||
}
|
||||
const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } }
|
||||
const prev = this.menu.getSnapshot()
|
||||
const same = prev.open && prev.hit !== null
|
||||
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
|
||||
@@ -101,13 +109,40 @@ export class SlashController {
|
||||
this.reduce({ type: 'close' })
|
||||
return
|
||||
}
|
||||
if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) {
|
||||
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.
|
||||
@@ -349,9 +384,14 @@ export class SlashController {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +351,57 @@ describe('track', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('programmatic source launcher', () => {
|
||||
it('opens only the requested source and reuses its ordinary pick span', async () => {
|
||||
const command = readySource('/', 'command', [{ name: 'goal' }])
|
||||
const skill = readySource('/', 'skill', [{ name: 'review' }])
|
||||
const { controller } = controllerBench([command.source, skill.source])
|
||||
const hit = {
|
||||
trigger: '/' as const,
|
||||
query: '',
|
||||
position: 'leading' as const,
|
||||
span: { start: 2, end: 5, draftRev: 7 },
|
||||
}
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
await tick()
|
||||
|
||||
expect(controller.launcher.getSnapshot()).toBe('command')
|
||||
expect(controller.menu.getSnapshot()).toMatchObject({
|
||||
open: true,
|
||||
hit,
|
||||
groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
|
||||
})
|
||||
controller.pick('command', 0)
|
||||
expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
|
||||
expect(skill.picks).toHaveLength(0)
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
|
||||
const command = readySource('/', 'command', [{ name: 'goal' }])
|
||||
const skill = readySource('/', 'skill', [{ name: 'review' }])
|
||||
const { controller } = controllerBench([command.source, skill.source])
|
||||
const hit = {
|
||||
trigger: '/' as const,
|
||||
query: '',
|
||||
position: 'leading' as const,
|
||||
span: { start: 0, end: 0, draftRev: 1 },
|
||||
}
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
controller.toggleSource('command', hit)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope-birth warm', () => {
|
||||
it('construction warms every source once with the session projection', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
|
||||
Reference in New Issue
Block a user