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:
6
packages/client/ui-input-trigger/README.i18n.yaml
Normal file
6
packages/client/ui-input-trigger/README.i18n.yaml
Normal file
@@ -0,0 +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 packages/client/ui-input-trigger/README.md
|
||||
README.md: f1858d3b506826d4e5eeb8d101b9bc216c1c0615
|
||||
README.zh.md: 655b3f0458062ae7b578375415a1e28f6d3171dd
|
||||
25
packages/client/ui-input-trigger/README.md
Normal file
25
packages/client/ui-input-trigger/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-client-ui-input-trigger
|
||||
|
||||
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.inputTriggers` owns the source roster and resolves one `InputTriggerController` 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/` 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; 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. 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 `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.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-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `InputTriggerService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the trigger pipeline is browser presentation only — picks produce `CommandClaim`/`ReferenceInsert` data whose model-visible consequences (host command execution; inserted reference text riding an ordinary prompt) are owned by the consuming host and input-machine packages.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need).
|
||||
- **`InputTriggerCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships.
|
||||
- **Overlay SlotMap merge home is split from slot ownership** — the sole `conversation.input.overlay` merge lives here, while ui-conversation owns its anchor, children declaration, and lifecycle because the dependency direction is ui-conversation → ui-input-trigger.
|
||||
25
packages/client/ui-input-trigger/README.zh.md
Normal file
25
packages/client/ui-input-trigger/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-client-ui-input-trigger
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
|
||||
分层:`src/core/` 是纯内核——`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` 文件是冻结的跨包约定;变更需经主线程仲裁。
|
||||
|
||||
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
|
||||
|
||||
`/client` 导出接口是插件主体(`apply`/`inject`)、`InputTriggerService`、`MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。触发流水线只是浏览器呈现——pick 产出 `CommandClaim`/`ReferenceInsert` 数据,其模型可见后果(宿主命令执行;插入的引用文本随普通提示词发送)由负责消费这些数据的宿主包与输入状态机包负责。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
|
||||
- **`InputTriggerCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;与设计系统图标枚举(iconFile 五变体家族)的接入将在该枚举交付后完成。
|
||||
- **overlay 的 SlotMap 合并归属与 slot 所有权分离**:唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 负责其锚点、children 声明和生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
|
||||
75
packages/client/ui-input-trigger/package.json
Normal file
75
packages/client/ui-input-trigger/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-input-trigger",
|
||||
"description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-input-trigger"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
108
packages/client/ui-input-trigger/src/client/MenuView.module.css
Normal file
108
packages/client/ui-input-trigger/src/client/MenuView.module.css
Normal 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);
|
||||
}
|
||||
116
packages/client/ui-input-trigger/src/client/MenuView.tsx
Normal file
116
packages/client/ui-input-trigger/src/client/MenuView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
packages/client/ui-input-trigger/src/client/contract.ts
Normal file
17
packages/client/ui-input-trigger/src/client/contract.ts
Normal 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
|
||||
}
|
||||
398
packages/client/ui-input-trigger/src/client/controller.ts
Normal file
398
packages/client/ui-input-trigger/src/client/controller.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
80
packages/client/ui-input-trigger/src/client/index.ts
Normal file
80
packages/client/ui-input-trigger/src/client/index.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
26
packages/client/ui-input-trigger/src/client/locales.ts
Normal file
26
packages/client/ui-input-trigger/src/client/locales.ts
Normal 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>
|
||||
107
packages/client/ui-input-trigger/src/client/service.ts
Normal file
107
packages/client/ui-input-trigger/src/client/service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
40
packages/client/ui-input-trigger/src/client/slots.ts
Normal file
40
packages/client/ui-input-trigger/src/client/slots.ts
Normal 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
|
||||
}
|
||||
57
packages/client/ui-input-trigger/src/core/contract.ts
Normal file
57
packages/client/ui-input-trigger/src/core/contract.ts
Normal 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
|
||||
63
packages/client/ui-input-trigger/src/core/detect.ts
Normal file
63
packages/client/ui-input-trigger/src/core/detect.ts
Normal 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
|
||||
}
|
||||
140
packages/client/ui-input-trigger/src/core/menu.ts
Normal file
140
packages/client/ui-input-trigger/src/core/menu.ts
Normal 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
|
||||
}
|
||||
6
packages/client/ui-input-trigger/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-input-trigger/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-input-trigger/src/index.ts
Normal file
9
packages/client/ui-input-trigger/src/index.ts
Normal 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 {}
|
||||
32
packages/client/ui-input-trigger/src/invariant.ts
Normal file
32
packages/client/ui-input-trigger/src/invariant.ts
Normal 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 */
|
||||
257
packages/client/ui-input-trigger/src/types.ts
Normal file
257
packages/client/ui-input-trigger/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
104
packages/client/ui-input-trigger/tests/apply.client.spec.ts
Normal file
104
packages/client/ui-input-trigger/tests/apply.client.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotRegistry: InputTriggerService mounts
|
||||
* as ctx.inputTriggers once its sessions dependency is up; the MenuView overlay
|
||||
* registration follows the slot declaration, resolves the per-session controller from the slot's
|
||||
* sessionId, and unregisters on fiber teardown.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
// Stand-in for the ui-conversation composer entry: declare the overlay
|
||||
// slot without providing ConversationController, which is not its lifecycle
|
||||
// signal.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
// Sessions face: mint one real scope for session 'a' and resolve it by id.
|
||||
const scope = createScope(ctx, sid('a'))
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots, locale }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
|
||||
expect(inject).toEqual(['sessions', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
|
||||
const { ctx, locale } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const t = locale.bind('slash.menu')
|
||||
expect(t('command')).toBe('命令')
|
||||
locale.setLocale('en')
|
||||
expect(t('skill')).toBe('Skills')
|
||||
expect(t('subagent')).toBe('Subagents')
|
||||
expect(t('loading')).toBe('Loading…')
|
||||
})
|
||||
|
||||
it('mounts ctx.inputTriggers once sessions is up, before any conversation service exists', async () => {
|
||||
const { ctx } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(ctx.get('inputTriggers')).toBeInstanceOf(InputTriggerService)
|
||||
})
|
||||
|
||||
it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
const entries = slots.entries('conversation.input.overlay')
|
||||
expect(entries[0]!.options.id).toBe('slash-menu')
|
||||
// Copy rides the standard locale seat, not the business face.
|
||||
expect(entries[0]!.locale).toBe('slash.menu')
|
||||
|
||||
const inputTriggers = ctx.get('inputTriggers') as InputTriggerService
|
||||
// StoredEntry.inject is declaration-typed ((...args: never[]) shape);
|
||||
// the erased registration widens it past a direct cast, so hop unknown.
|
||||
const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
|
||||
const injected = injectEntry(sid('a'))
|
||||
const controller = inputTriggers.sessionOf(
|
||||
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
|
||||
)
|
||||
expect(injected.menu).toBe(controller.menu)
|
||||
// The pick face routes into the controller pipeline (closed menu → no-op).
|
||||
injected.onPick('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// The dismiss face routes into the controller too (closed menu → no-op).
|
||||
injected.onDismiss()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// An unknown session id fails loud (no silent scope miss).
|
||||
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
|
||||
it('fiber teardown removes the overlay entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
expect(ctx.get('inputTriggers')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// detectTrigger word-boundary, position, guard-tier, and span behavior.
|
||||
// URL rule pinned here: '/' is dead when its predecessor is
|
||||
// another '/' (second slash of '//') or a ':' itself preceded by a
|
||||
// non-whitespace char (scheme separator) — this is the concrete rule chosen
|
||||
// to honor "no trigger inside URLs".
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectTrigger } from '../src/core/detect.ts'
|
||||
import type { TriggerGuard } from '../src/types.ts'
|
||||
|
||||
const plain: TriggerGuard = { tier: 'plain' }
|
||||
const claimed: TriggerGuard = { tier: 'claimed' }
|
||||
const frozen: TriggerGuard = { tier: 'frozen' }
|
||||
|
||||
/** Hit at the end of the draft under the plain tier. */
|
||||
const atEnd = (draft: string, guard: TriggerGuard = plain) => detectTrigger(draft, draft.length, guard)
|
||||
|
||||
describe('detectTrigger word boundaries', () => {
|
||||
it('triggers at start of draft', () => {
|
||||
expect(atEnd('/go')).toMatchObject({ trigger: '/', query: 'go', position: 'leading' })
|
||||
expect(atEnd('@wo')).toMatchObject({ trigger: '@', query: 'wo', position: 'leading' })
|
||||
})
|
||||
|
||||
it('triggers after whitespace, newline, and punctuation', () => {
|
||||
expect(atEnd('say /co')).toMatchObject({ trigger: '/', query: 'co' })
|
||||
expect(atEnd('line1\n/go')).toMatchObject({ trigger: '/', query: 'go', position: 'inline' })
|
||||
expect(atEnd('see (/go')).toMatchObject({ trigger: '/', query: 'go' })
|
||||
expect(atEnd('ping @wo')).toMatchObject({ trigger: '@', query: 'wo' })
|
||||
})
|
||||
|
||||
it('does not trigger after a word character', () => {
|
||||
expect(atEnd('user@host')).toBeNull()
|
||||
expect(atEnd('a/b')).toBeNull()
|
||||
expect(atEnd('foo_1@bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not trigger on URL slashes', () => {
|
||||
// Both '//' slashes: first blocked by the ':' rule, second by the '/' rule.
|
||||
expect(atEnd('https://example')).toBeNull()
|
||||
expect(atEnd('see https://example')).toBeNull()
|
||||
// Path slashes deeper in the URL sit after word chars.
|
||||
expect(atEnd('https://a.b/c/d')).toBeNull()
|
||||
// Single slash after a scheme-like colon (mailto:/, C:/).
|
||||
expect(atEnd('C:/path')).toBeNull()
|
||||
})
|
||||
|
||||
it('still triggers when a colon is not a scheme separator', () => {
|
||||
// ':' preceded by whitespace / at index 0 is ordinary punctuation.
|
||||
expect(atEnd('note: /go')).toMatchObject({ trigger: '/', query: 'go' })
|
||||
expect(atEnd(':/go')).toMatchObject({ trigger: '/', query: 'go' })
|
||||
})
|
||||
|
||||
it('stops the backward scan at whitespace', () => {
|
||||
// Space after the token: no trigger at the caret anymore.
|
||||
expect(atEnd('/goal x')).toBeNull()
|
||||
expect(atEnd('@worker done')).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the nearest trigger left of the caret', () => {
|
||||
expect(atEnd('/goal @wor')).toMatchObject({ trigger: '@', query: 'wor' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectTrigger position', () => {
|
||||
it('treats a draft whose leading trim (incl. newlines) starts at the token as leading', () => {
|
||||
expect(atEnd('\n\n/goal')).toMatchObject({ position: 'leading' })
|
||||
expect(atEnd(' \n /goal')).toMatchObject({ position: 'leading' })
|
||||
})
|
||||
|
||||
it('treats a token after non-whitespace text as inline', () => {
|
||||
expect(atEnd('第一行\n/goal')).toMatchObject({ position: 'inline' })
|
||||
expect(atEnd('a /goal')).toMatchObject({ position: 'inline' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectTrigger guard tiers', () => {
|
||||
it('claimed suppresses "/" everywhere but keeps "@"', () => {
|
||||
expect(atEnd('/co', claimed)).toBeNull()
|
||||
expect(atEnd('args /path', claimed)).toBeNull()
|
||||
expect(atEnd('/goal @wor', claimed)).toMatchObject({ trigger: '@', query: 'wor' })
|
||||
})
|
||||
|
||||
it('a suppressed "/" is scanned through like an ordinary char', () => {
|
||||
// '/x' right of the caret path: scan passes the dead '/' and hits nothing.
|
||||
expect(detectTrigger('/goal /x', 8, claimed)).toBeNull()
|
||||
})
|
||||
|
||||
it('frozen suppresses both triggers', () => {
|
||||
expect(atEnd('/co', frozen)).toBeNull()
|
||||
expect(atEnd('@wo', frozen)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectTrigger span and query', () => {
|
||||
it('spans trigger char to caret with a placeholder draftRev', () => {
|
||||
const hit = detectTrigger('say /goal', 9, plain)
|
||||
expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 })
|
||||
expect(hit?.query).toBe('goal')
|
||||
})
|
||||
|
||||
it('cuts the query at a mid-token caret', () => {
|
||||
const hit = detectTrigger('/goal', 3, plain)
|
||||
expect(hit).toMatchObject({ query: 'go', span: { start: 0, end: 3 } })
|
||||
})
|
||||
|
||||
it('returns null at caret 0 and on empty drafts', () => {
|
||||
expect(detectTrigger('', 0, plain)).toBeNull()
|
||||
expect(detectTrigger('/goal', 0, plain)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles multi-line drafts with the token on a later line', () => {
|
||||
const draft = 'first line\nsecond /com'
|
||||
const hit = detectTrigger(draft, draft.length, plain)
|
||||
expect(hit).toMatchObject({ trigger: '/', query: 'com', position: 'inline', span: { start: 18, end: 22 } })
|
||||
})
|
||||
})
|
||||
215
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
Normal file
215
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// menuReduce generation gating, auto-close, silent group removal, cyclic
|
||||
// highlight movement, stale/no-op reference identity; exactMatch lookup.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MenuState, TriggerHit } from '../src/core/contract.ts'
|
||||
import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/menu.ts'
|
||||
|
||||
const hit = (query = ''): TriggerHit => ({
|
||||
trigger: '/',
|
||||
query,
|
||||
position: 'leading',
|
||||
span: { start: 0, end: 1 + query.length, draftRev: 1 },
|
||||
})
|
||||
|
||||
/** Seed sources onto the closed state and open a first generation. */
|
||||
function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
|
||||
return menuReduce(seedGroups(MENU_CLOSED, sources), { type: 'hit', hit: h })
|
||||
}
|
||||
|
||||
const item = (name: string) => ({ name })
|
||||
|
||||
describe('menuReduce hit', () => {
|
||||
it('opens a new generation with all groups pending', () => {
|
||||
const s = open(['command', 'skill'])
|
||||
expect(s.open).toBe(true)
|
||||
expect(s.generation).toBe(1)
|
||||
expect(s.groups).toEqual([
|
||||
{ source: 'command', status: 'pending', items: [] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
])
|
||||
expect(s.highlight).toBeNull()
|
||||
})
|
||||
|
||||
it('re-hit resets ready groups to pending under a bumped generation', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
s = menuReduce(s, { type: 'hit', hit: hit('g') })
|
||||
expect(s.generation).toBe(2)
|
||||
expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }])
|
||||
expect(s.highlight).toBeNull()
|
||||
})
|
||||
|
||||
it('null hit closes; closing an already-closed state is a no-op reference', () => {
|
||||
const s = open(['command'])
|
||||
const c = menuReduce(s, { type: 'hit', hit: null })
|
||||
expect(c.open).toBe(false)
|
||||
expect(c.groups).toEqual([])
|
||||
expect(menuReduce(c, { type: 'hit', hit: null })).toBe(c)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce source-settled', () => {
|
||||
it('marks the group ready and highlights the first item', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.groups[1]).toEqual({ source: 'skill', status: 'ready', items: [item('commit')] })
|
||||
expect(s.groups[0]!.status).toBe('pending')
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('keeps an existing valid highlight when a later group settles', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('drops settlements from a stale generation by reference', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'hit', hit: hit('g') }) // generation 2
|
||||
const next = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(next).toBe(s)
|
||||
})
|
||||
|
||||
it('drops settlements while closed and for unknown sources by reference', () => {
|
||||
const closed = menuReduce(open(['command']), { type: 'close' })
|
||||
expect(menuReduce(closed, { type: 'source-settled', generation: 1, source: 'command', items: [] })).toBe(closed)
|
||||
const s = open(['command'])
|
||||
expect(menuReduce(s, { type: 'source-settled', generation: 1, source: 'ghost', items: [] })).toBe(s)
|
||||
})
|
||||
|
||||
it('treats omitted items as empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command' })
|
||||
expect(s.groups[0]).toEqual({ source: 'command', status: 'ready', items: [] })
|
||||
expect(s.open).toBe(true) // skill still pending
|
||||
})
|
||||
|
||||
it('auto-closes when every group settles ready and empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
||||
expect(s.open).toBe(false)
|
||||
expect(s.groups).toEqual([])
|
||||
})
|
||||
|
||||
it('stays open when one group is empty but another has items', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.open).toBe(true)
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce source-failed', () => {
|
||||
it('silently removes the failed group', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.groups.map(g => g.source)).toEqual(['skill'])
|
||||
expect(s.open).toBe(true)
|
||||
})
|
||||
|
||||
it('closes when the last group fails', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.open).toBe(false)
|
||||
})
|
||||
|
||||
it('closes when the surviving groups are all ready and empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.open).toBe(false)
|
||||
})
|
||||
|
||||
it('moves the highlight off the failed group', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('drops stale-generation and unknown-source failures by reference', () => {
|
||||
const s = open(['command'])
|
||||
expect(menuReduce(s, { type: 'source-failed', generation: 0, source: 'command' })).toBe(s)
|
||||
expect(menuReduce(s, { type: 'source-failed', generation: 1, source: 'ghost' })).toBe(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce move', () => {
|
||||
/** Two ready groups: command [goal, model], skill [commit]. */
|
||||
function ready(): MenuState {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
return s
|
||||
}
|
||||
|
||||
it('cycles forward across groups and wraps', () => {
|
||||
let s = ready()
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 1 })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('cycles backward and wraps to the last item', () => {
|
||||
let s = ready()
|
||||
s = menuReduce(s, { type: 'move', dir: -1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('skips pending groups', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('enters from null highlight at either end', () => {
|
||||
const base = { ...ready(), highlight: null }
|
||||
expect(menuReduce(base, { type: 'move', dir: 1 }).highlight).toEqual({ source: 'command', index: 0 })
|
||||
expect(menuReduce(base, { type: 'move', dir: -1 }).highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('is a no-op reference when closed, without positions, or single-item', () => {
|
||||
const closed = menuReduce(ready(), { type: 'close' })
|
||||
expect(menuReduce(closed, { type: 'move', dir: 1 })).toBe(closed)
|
||||
const pending = open(['command'])
|
||||
expect(menuReduce(pending, { type: 'move', dir: 1 })).toBe(pending)
|
||||
let single = open(['command'])
|
||||
single = menuReduce(single, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(menuReduce(single, { type: 'move', dir: 1 })).toBe(single)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce close', () => {
|
||||
it('clears everything but keeps the generation for stale-drop', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'close' })
|
||||
expect(s).toMatchObject({ open: false, hit: null, groups: [], highlight: null, generation: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('exactMatch', () => {
|
||||
const groups: MenuState['groups'] = [
|
||||
{ source: 'command', status: 'ready', items: [item('goal'), item('model')] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
]
|
||||
|
||||
it('finds an exact name in a ready group', () => {
|
||||
expect(exactMatch(groups, 'command', 'model')).toEqual(item('model'))
|
||||
})
|
||||
|
||||
it('returns null on name miss, non-ready group, and unknown source', () => {
|
||||
expect(exactMatch(groups, 'command', 'goa')).toBeNull()
|
||||
expect(exactMatch(groups, 'skill', 'commit')).toBeNull()
|
||||
expect(exactMatch(groups, 'ghost', 'goal')).toBeNull()
|
||||
})
|
||||
})
|
||||
198
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
Normal file
198
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MenuView rendering spec, props-direct: closed store
|
||||
* renders null, groups render in roster order under localized title rows
|
||||
* (unknown sources fall back to the raw name) with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected, and
|
||||
* the list height clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import { MenuView } from '../src/client/MenuView.tsx'
|
||||
|
||||
const hit: TriggerHit = {
|
||||
trigger: '/',
|
||||
query: 'g',
|
||||
position: 'leading',
|
||||
span: { start: 0, end: 2, draftRev: 1 },
|
||||
}
|
||||
|
||||
const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
|
||||
|
||||
function openState(partial?: Partial<MenuState>): MenuState {
|
||||
return {
|
||||
open: true,
|
||||
hit,
|
||||
generation: 1,
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
highlight: { source: 'command', index: 0 },
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the
|
||||
// default locale); the stub mirrors the LocaleRuntime key fallback, so an
|
||||
// unknown source comes back verbatim (its raw name).
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function mount(state: MenuState) {
|
||||
const menu = createSnapshotStore<MenuState>(state)
|
||||
const onPick = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
|
||||
return { menu, onPick, onDismiss, view }
|
||||
}
|
||||
|
||||
/** The non-interactive group title rows (role=presentation), in document order. */
|
||||
function titles(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
|
||||
.map(el => el.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('MenuView', () => {
|
||||
it('renders null while closed and appears when the store opens', () => {
|
||||
const { menu, view } = mount(CLOSED)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
act(() => { menu.set(openState()) })
|
||||
expect(screen.queryByRole('listbox')).not.toBeNull()
|
||||
act(() => { menu.set(CLOSED) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('renders ready groups as option rows and pending groups as loading rows', () => {
|
||||
mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
|
||||
expect(screen.queryByText('正在加载…')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
|
||||
const { view } = mount(openState({
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
|
||||
{ source: 'hollow', status: 'ready', items: [] },
|
||||
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
}))
|
||||
expect(titles(view.container)).toEqual(['命令', 'mystery', '技能'])
|
||||
})
|
||||
|
||||
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
|
||||
mount(openState({ highlight: { source: 'command', index: 1 } }))
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options[1]!.id).toBeTruthy()
|
||||
expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
|
||||
expect(options[1]!.getAttribute('aria-selected')).toBe('true')
|
||||
expect(options[0]!.getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('omits aria-activedescendant without a highlight', () => {
|
||||
mount(openState({ highlight: null }))
|
||||
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolls the highlighted option into view when the highlight moves', () => {
|
||||
const { menu } = mount(openState())
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('re-fits the height when the window resizes', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pointerdown inside the list does not dismiss', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
fireEvent.pointerDown(screen.getAllByRole('option')[0]!)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => {
|
||||
const menu = createSnapshotStore<MenuState>(openState())
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<div data-composer-card="">
|
||||
<MenuView menu={menu} onPick={vi.fn()} onDismiss={onDismiss} t={t} />
|
||||
<button type="button" data-testid="composer-button" />
|
||||
</div>,
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByTestId('composer-button'))
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a pointerdown whose target is not a DOM node', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
const ev = new Event('pointerdown', { bubbles: true })
|
||||
Object.defineProperty(ev, 'target', { value: {} })
|
||||
document.dispatchEvent(ev)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closing the menu removes the dismiss listener', () => {
|
||||
const { menu, onDismiss } = mount(openState())
|
||||
act(() => { menu.set(CLOSED) })
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
|
||||
const { onPick } = mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
const notPrevented = fireEvent.mouseDown(options[1]!)
|
||||
// fireEvent returns false when preventDefault was called.
|
||||
expect(notPrevented).toBe(false)
|
||||
expect(onPick).toHaveBeenCalledWith('command', 1)
|
||||
})
|
||||
})
|
||||
831
packages/client/ui-input-trigger/tests/service.client.spec.ts
Normal file
831
packages/client/ui-input-trigger/tests/service.client.spec.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* Slash pipeline spec over the split architecture. InputTriggerService keeps only
|
||||
* the source roster (duplicate throw, disposal dropping live menu groups in
|
||||
* every session controller) and per-session controller resolution; all
|
||||
* interaction — track → menu store, pick execution via the scoped input
|
||||
* events, keyboard arbitration, space/enter adjudication, and the
|
||||
* scope-birth roster warm — is InputTriggerController behavior, tested on a real
|
||||
* session scope (createScope).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type {
|
||||
BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
|
||||
ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SourceRoster, TriggerChar,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
interface PendingFetch {
|
||||
resolve: (items: readonly InputTriggerCandidate[]) => void
|
||||
reject: (err: unknown) => void
|
||||
query: string
|
||||
signal: AbortSignal
|
||||
session: ClientSessionContext
|
||||
}
|
||||
|
||||
/** Deferred-candidates source: settle each fetch by hand; warm is a spy. */
|
||||
function deferredSource(trigger: TriggerChar, name: string, over: Partial<InputTriggerSource> = {}) {
|
||||
const pending: PendingFetch[] = []
|
||||
const warm = vi.fn()
|
||||
const source: InputTriggerSource = {
|
||||
trigger,
|
||||
name,
|
||||
candidates: (session, req) => new Promise<readonly InputTriggerCandidate[]>((resolve, reject) => {
|
||||
pending.push({ resolve, reject, query: req.query, signal: req.signal, session })
|
||||
}),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
...over,
|
||||
}
|
||||
return { source, pending, warm }
|
||||
}
|
||||
|
||||
/** Source whose candidates resolve immediately; picks are recorded. */
|
||||
function readySource(
|
||||
trigger: TriggerChar, name: string, items: readonly InputTriggerCandidate[], onPick?: (pick: InputTriggerPick) => PickOutcome,
|
||||
) {
|
||||
const picks: InputTriggerPick[] = []
|
||||
const source: InputTriggerSource = {
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve(items),
|
||||
onPick: (pick) => {
|
||||
picks.push(pick)
|
||||
return onPick?.(pick)
|
||||
},
|
||||
}
|
||||
return { source, picks }
|
||||
}
|
||||
|
||||
const claimOf = (token: string): CommandClaim =>
|
||||
({ token, submit: () => Promise.resolve({ kind: 'success' }) })
|
||||
|
||||
/** One microtask hop: lets settled candidate promises flow into the store. */
|
||||
const tick = () => Promise.resolve()
|
||||
|
||||
/** Direct controller bench: real scope tag + live roster array. */
|
||||
function controllerBench(sources: InputTriggerSource[] = [], key = 'a') {
|
||||
const root = new Context()
|
||||
const scope = createScope(root, sid(key))
|
||||
const roster: SourceRoster = {
|
||||
sources: trigger => sources.filter(s => s.trigger === trigger),
|
||||
all: () => sources,
|
||||
}
|
||||
const controller = new InputTriggerController({ actx: scope.ctx, sessionId: sid(key), roster })
|
||||
return { root, actx: scope.ctx, controller, sources }
|
||||
}
|
||||
|
||||
/** Real-service bench: a sessions face resolving scope tags to session ids. */
|
||||
async function serviceBench() {
|
||||
const root = new Context()
|
||||
root.provide('sessions', {
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
await root.plugin(InputTriggerService).await()
|
||||
const inputTriggers = root.get('inputTriggers') as InputTriggerService
|
||||
const mint = (key: string) => {
|
||||
const scope = createScope(root, sid(key))
|
||||
return { actx: scope.ctx, fiber: scope.fiber }
|
||||
}
|
||||
return { root, inputTriggers, mint }
|
||||
}
|
||||
|
||||
describe('registerSource', () => {
|
||||
it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
|
||||
const { inputTriggers } = await serviceBench()
|
||||
inputTriggers.registerSource(readySource('/', 'command', []).source)
|
||||
expect(() => inputTriggers.registerSource(readySource('/', 'command', []).source))
|
||||
.toThrow(/already registered/)
|
||||
inputTriggers.registerSource(readySource('@', 'command', []).source)
|
||||
})
|
||||
|
||||
it('disposal frees the name and drops the live menu group in every session controller', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const a = readySource('/', 'alpha', [{ name: 'one' }])
|
||||
const b = deferredSource('/', 'beta')
|
||||
inputTriggers.registerSource(a.source)
|
||||
const disposeB = inputTriggers.registerSource(b.source)
|
||||
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
ca.track('/o', 2, { tier: 'plain' }, 1)
|
||||
cb.track('/o', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
||||
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
||||
|
||||
disposeB()
|
||||
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
||||
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
||||
// The name is free again, and a stale double-dispose stays a no-op.
|
||||
disposeB()
|
||||
inputTriggers.registerSource(deferredSource('/', 'beta').source)
|
||||
})
|
||||
|
||||
it('a source registered after controller birth warms in every live controller', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
|
||||
inputTriggers.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, inputTriggers, mint } = await serviceBench()
|
||||
const controller = inputTriggers.sessionOf(mint('a').actx)
|
||||
const fiber = root.plugin({
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.effect(
|
||||
() => inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
|
||||
'test: slash source',
|
||||
)
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
|
||||
await fiber.dispose()
|
||||
// Group dropped with the fiber; a fresh track finds no sources → closed.
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionOf', () => {
|
||||
it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const a = mint('a')
|
||||
const first = inputTriggers.sessionOf(a.actx)
|
||||
expect(inputTriggers.sessionOf(a.actx)).toBe(first)
|
||||
expect(inputTriggers.sessionOf(mint('b').actx)).not.toBe(first)
|
||||
})
|
||||
|
||||
it('throws off an unscoped context', async () => {
|
||||
const { root, inputTriggers } = await serviceBench()
|
||||
expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
|
||||
})
|
||||
|
||||
it('warms the roster once at controller birth with the session projection', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const sub = deferredSource('@', 'subagent')
|
||||
inputTriggers.registerSource(cmd.source)
|
||||
inputTriggers.registerSource(sub.source)
|
||||
const a = mint('a')
|
||||
inputTriggers.sessionOf(a.actx)
|
||||
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
// Re-resolution of the resident controller never re-warms.
|
||||
inputTriggers.sessionOf(a.actx)
|
||||
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
|
||||
const a = mint('a')
|
||||
const controller = inputTriggers.sessionOf(a.actx)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
|
||||
await a.fiber.dispose()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
const again = mint('a')
|
||||
expect(inputTriggers.sessionOf(again.actx)).not.toBe(controller)
|
||||
})
|
||||
|
||||
it('two sessions are isolated: one menu opening never touches the other', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const src = deferredSource('/', 'command')
|
||||
inputTriggers.registerSource(src.source)
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
|
||||
ca.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(ca.menu.getSnapshot().open).toBe(true)
|
||||
expect(cb.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
src.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
||||
expect(cb.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('track', () => {
|
||||
it('drives seed → pending → ready through the store', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
let state = controller.menu.getSnapshot()
|
||||
expect(state.open).toBe(true)
|
||||
expect(state.groups).toEqual([
|
||||
{ source: 'command', status: 'pending', items: [] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
])
|
||||
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
state = controller.menu.getSnapshot()
|
||||
expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
|
||||
expect(state.groups[1]!.status).toBe('pending')
|
||||
expect(state.highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('stamps the caller draftRev into the hit span', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 7)
|
||||
expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
|
||||
})
|
||||
|
||||
it('candidates receive the session projection, identity only', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
|
||||
})
|
||||
|
||||
it('query refinement supersedes the old generation and aborts its fetch', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
const gen1 = controller.menu.getSnapshot().generation
|
||||
controller.track('/go', 3, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
|
||||
// A late settle of the aborted fetch is dropped even before the
|
||||
// generation gate: the group stays pending until the live fetch lands.
|
||||
cmd.pending[0]!.resolve([{ name: 'stale' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
|
||||
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
||||
})
|
||||
|
||||
it('same hit re-track refreshes the span stamp without refetching', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
// Same token under the caret, later revision (an edit past the caret).
|
||||
controller.track('/g x', 2, { tier: 'plain' }, 2)
|
||||
expect(cmd.pending).toHaveLength(1)
|
||||
expect(controller.menu.getSnapshot().generation).toBe(1)
|
||||
})
|
||||
|
||||
it('no live trigger closes the menu and aborts the fetch', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
controller.track('hello', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('a trigger with no registered sources never opens', () => {
|
||||
const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
|
||||
controller.track('@w', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('trigger switch reseeds the roster', () => {
|
||||
const { controller } = controllerBench([
|
||||
deferredSource('/', 'command').source,
|
||||
deferredSource('@', 'subagent').source,
|
||||
])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
|
||||
controller.track('@w', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
|
||||
})
|
||||
|
||||
it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
controller.track('/zzz', 4, { tier: 'plain' }, 1)
|
||||
cmd.pending[0]!.resolve([])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
skill.pending[0]!.resolve([])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a rejecting source logs and silently drops its group', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
skill.pending[0]!.reject(new Error('boom'))
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
const state = controller.menu.getSnapshot()
|
||||
expect(state.groups.map(g => g.source)).toEqual(['command'])
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
const sub = deferredSource('@', 'subagent')
|
||||
controllerBench([cmd.source, sub.source])
|
||||
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
})
|
||||
|
||||
it('hook-less sources are skipped', () => {
|
||||
const bare: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'bare',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
}
|
||||
const cmd = deferredSource('/', 'command')
|
||||
// No throw on the hook-less source; the implementing one still warms.
|
||||
controllerBench([bare, cmd.source])
|
||||
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispose inerts every verb', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
controller.dispose()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(controller.arbitrate('down', false)).toBe('pass')
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
controller.pick('command', 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick / scoped input events', () => {
|
||||
function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
|
||||
const bench = controllerBench([cmd.source])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
const inserts: InsertReferenceRequest[] = []
|
||||
bench.actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
bench.actx.on('slash/input-insert-reference', (req) => {
|
||||
inserts.push(req)
|
||||
return true
|
||||
})
|
||||
bench.controller.track('/g', 2, { tier: 'plain' }, 3)
|
||||
return { ...bench, cmd, begins, inserts }
|
||||
}
|
||||
|
||||
it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller, cmd, begins } = pickBench(() => ({ claim }))
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(cmd.picks).toHaveLength(1)
|
||||
expect(cmd.picks[0]).toMatchObject({
|
||||
candidate: { name: 'goal' },
|
||||
session: { sessionId: sid('a') },
|
||||
position: 'leading',
|
||||
via: 'menu',
|
||||
span: { start: 0, end: 2, draftRev: 3 },
|
||||
})
|
||||
expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('routes an insert outcome through the scoped insert-reference event', async () => {
|
||||
const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
|
||||
const { controller, inserts } = pickBench(() => ({ insert }))
|
||||
await tick()
|
||||
controller.pick('command', 1)
|
||||
expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
})
|
||||
|
||||
it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
|
||||
const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
|
||||
const texts: Array<{ text: string; span: unknown }> = []
|
||||
actx.on('slash/input-insert-text', (req) => {
|
||||
texts.push(req)
|
||||
return true
|
||||
})
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a text outcome the input declines answers false on the space path', async () => {
|
||||
const src: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'command',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
matchSpace: () => ({ text: '/goal ' }),
|
||||
}
|
||||
const { controller, actx } = controllerBench([src])
|
||||
actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
|
||||
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
})
|
||||
|
||||
it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
|
||||
const claim = claimOf('/goal ')
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
|
||||
const { root, controller } = controllerBench([cmd.source])
|
||||
const foreign: BeginCommandRequest[] = []
|
||||
const rootSeen: BeginCommandRequest[] = []
|
||||
createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
|
||||
foreign.push(req)
|
||||
return true
|
||||
})
|
||||
// Untagged root listeners are admitted globally (the carrier contract).
|
||||
root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
|
||||
controller.track('/g', 2, { tier: 'plain' }, 3)
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(foreign).toHaveLength(0)
|
||||
expect(rootSeen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("'handled' and undefined outcomes only close the menu", async () => {
|
||||
const { controller, begins, inserts } = pickBench(() => 'handled')
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(begins).toHaveLength(0)
|
||||
expect(inserts).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('closed menu / vanished candidate picks are no-ops', async () => {
|
||||
const { controller, cmd } = pickBench(() => undefined)
|
||||
await tick()
|
||||
controller.pick('command', 9)
|
||||
controller.pick('ghost', 0)
|
||||
expect(cmd.picks).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lexicon', () => {
|
||||
function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
|
||||
return {
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
...(hasHook ? { lexicon: () => roll } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
|
||||
const seen: unknown[] = []
|
||||
const skill: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: (projection) => {
|
||||
seen.push(projection)
|
||||
return ['commit-helper', 'review']
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([
|
||||
lexSource('/', 'command', undefined, false), // no hook: not polled
|
||||
skill,
|
||||
lexSource('@', 'subagent', ['worker-1']),
|
||||
])
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect([...rolls.keys()]).toEqual(['/', '@'])
|
||||
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
||||
expect(rolls.get('@')).toEqual(['worker-1'])
|
||||
expect(seen).toEqual([{ sessionId: sid('a') }])
|
||||
})
|
||||
|
||||
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
||||
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('two sources on one trigger concatenate in registration order', () => {
|
||||
const { controller } = controllerBench([
|
||||
lexSource('/', 'skill', ['b', 'a']),
|
||||
lexSource('/', 'prompt', ['c']),
|
||||
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
||||
])
|
||||
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: InputTriggerSource = {
|
||||
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: InputTriggerSource = {
|
||||
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', () => {
|
||||
async function menuBench() {
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
return { controller, cmd }
|
||||
}
|
||||
|
||||
it('up/down move the highlight and are consumed', async () => {
|
||||
const { controller } = await menuBench()
|
||||
expect(controller.arbitrate('down', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
|
||||
expect(controller.arbitrate('up', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('enter picks the highlight through the pipeline', async () => {
|
||||
const { controller, cmd } = await menuBench()
|
||||
expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
|
||||
expect(cmd.picks[0]!.candidate.name).toBe('goal')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('escape closes and consumes', async () => {
|
||||
const { controller } = await menuBench()
|
||||
expect(controller.arbitrate('escape', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('IME composition passes every key untouched', async () => {
|
||||
const { controller } = await menuBench()
|
||||
for (const key of ['up', 'down', 'enter', 'escape'] as const) {
|
||||
expect(controller.arbitrate(key, true)).toBe('pass')
|
||||
}
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('closed menu passes; an open menu without a highlight passes enter', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
// Open with the only group still pending: nothing to pick yet.
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSpace', () => {
|
||||
function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
|
||||
return {
|
||||
trigger: '/',
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
matchSpace: (_session, token) => {
|
||||
calls.push(`${name}:${token}`)
|
||||
return answer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
|
||||
const calls: string[] = []
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller, actx } = controllerBench([
|
||||
// Hook-less source: never polled, so it must not shadow the order below.
|
||||
{ trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
|
||||
spaceSource('first', undefined, calls),
|
||||
spaceSource('second', { claim }, calls),
|
||||
spaceSource('third', { claim: claimOf('/x ') }, calls),
|
||||
])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(true)
|
||||
expect(calls).toEqual(['first:/goal', 'second:/goal'])
|
||||
expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
|
||||
})
|
||||
|
||||
it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
|
||||
const calls: string[] = []
|
||||
const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
||||
declined.actx.on('slash/input-begin-command', () => undefined)
|
||||
declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(declined.controller.onSpace()).toBe(false)
|
||||
|
||||
const handled = controllerBench([spaceSource('command', 'handled', calls)])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
handled.actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(handled.controller.onSpace()).toBe(true)
|
||||
expect(begins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('answers false off a non-leading hit or with no tracked hit', () => {
|
||||
const calls: string[] = []
|
||||
const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
|
||||
controller.track('say /goal', 9, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjudicate', () => {
|
||||
const enterSource = (
|
||||
trigger: TriggerChar, name: string,
|
||||
matchEnter?: InputTriggerSource['matchEnter'],
|
||||
): InputTriggerSource => ({
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
...(matchEnter !== undefined ? { matchEnter } : {}),
|
||||
})
|
||||
|
||||
it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
|
||||
const calls: string[] = []
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller } = controllerBench([
|
||||
enterSource('/', 'silent'),
|
||||
enterSource('/', 'first', (session, line) => {
|
||||
expect(session).toEqual({ sessionId: sid('a') })
|
||||
calls.push(`first:${line}`)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
enterSource('/', 'second', (_session, line) => {
|
||||
calls.push(`second:${line}`)
|
||||
return Promise.resolve({ claim })
|
||||
}),
|
||||
enterSource('/', 'third', () => {
|
||||
calls.push('third')
|
||||
return Promise.resolve('handled')
|
||||
}),
|
||||
])
|
||||
const result = await controller.adjudicate('/goal make it fast', new AbortController().signal)
|
||||
expect(result).toEqual({ claim })
|
||||
expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
|
||||
})
|
||||
|
||||
it('skips sources of another trigger; all-undefined answers undefined', async () => {
|
||||
const atHook = vi.fn(() => Promise.resolve('handled' as const))
|
||||
const { controller } = controllerBench([
|
||||
enterSource('@', 'subagent', atHook),
|
||||
enterSource('/', 'command', () => Promise.resolve(undefined)),
|
||||
])
|
||||
await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined()
|
||||
expect(atHook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a rejecting source rejects the whole adjudication', async () => {
|
||||
const { controller } = controllerBench([
|
||||
enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
|
||||
enterSource('/', 'late', () => Promise.resolve('handled')),
|
||||
])
|
||||
await expect(controller.adjudicate('/goal x', new AbortController().signal))
|
||||
.rejects.toThrow('warmup failed')
|
||||
})
|
||||
|
||||
it('an aborted attempt signal stops the poll', async () => {
|
||||
const hook = vi.fn(() => Promise.resolve(undefined))
|
||||
const { controller } = controllerBench([enterSource('/', 'command', hook)])
|
||||
const abort = new AbortController()
|
||||
abort.abort(new Error('attempt released'))
|
||||
await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released')
|
||||
expect(hook).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
30
packages/client/ui-input-trigger/tsconfig.json
Normal file
30
packages/client/ui-input-trigger/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-input-trigger/tsdown.config.ts
Normal file
3
packages/client/ui-input-trigger/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-input-trigger', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user