Typing /context in the composer opens a centered dialog with the current composition (provider-anchored headline, stacked bar, legend) and the last-10-turn trend chart — the same pushed contextTimeline projection the tab renders, no RPC, no polling. The command is the plugin's own '/' trigger source, fully client-side: no host descriptor, no session log records, nothing model-visible. The composer token is consumed only when the modal closes (deferred slash/input-consume-token with span / bare-token guards, fail-soft if the draft changed meanwhile). Esc, backdrop click, and the close button all close it and restore composer focus. Also extracts the provider-anchored headline derivation shared by the tab and the modal (headline.ts).
56 lines
2.4 KiB
TypeScript
56 lines
2.4 KiB
TypeScript
/**
|
|
* `/context` — a client-owned slash command that opens the context modal
|
|
* (current composition + recent trend) in the center of the page.
|
|
*
|
|
* Implemented as the plugin's own '/' trigger source instead of a host
|
|
* command: nothing is dispatched to the host, no session log records are
|
|
* written, and nothing becomes model-visible — the invocation never enters
|
|
* the message history. Both paths answer `'handled'` and open the modal,
|
|
* leaving the `/context` token in the composer while it is open; the modal's
|
|
* close path consumes the token then (see modalStore.ts).
|
|
*/
|
|
|
|
import { modalStoreOf, setPendingConsume } from './modalStore'
|
|
import type { ClientCtx, InputTriggersFace } from './services'
|
|
import type { ViewKit } from './viewkit'
|
|
|
|
const COMMAND = 'context'
|
|
const LINE = '/' + COMMAND
|
|
|
|
export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void {
|
|
ctx.effect(() => {
|
|
// Soft dependency (ctx.get, not the inject list): a harness without the
|
|
// input-trigger service keeps the tab working and only the command
|
|
// absent.
|
|
const inputTriggers = ctx.get('inputTriggers') as InputTriggersFace | undefined
|
|
if (inputTriggers === undefined) return () => {}
|
|
|
|
return inputTriggers.registerSource({
|
|
trigger: '/',
|
|
name: COMMAND,
|
|
order: 1,
|
|
candidates: (_session, req) => {
|
|
if (req.position !== 'leading') return Promise.resolve([])
|
|
const query = req.query.trim().toLowerCase()
|
|
if (query !== '' && !COMMAND.startsWith(query)) return Promise.resolve([])
|
|
return Promise.resolve([{ name: COMMAND, description: kit.t('cmd.desc') }])
|
|
},
|
|
onPick: (pick) => {
|
|
// Open and remember the token span: the modal's close path consumes
|
|
// it (span CAS — a draft changed meanwhile is left untouched).
|
|
setPendingConsume(pick.session.sessionId, { kind: 'span', span: pick.span })
|
|
modalStoreOf(pick.session.sessionId).set(true)
|
|
return 'handled'
|
|
},
|
|
matchEnter: (session, line) => {
|
|
if (line !== LINE) return Promise.resolve(undefined)
|
|
// Same deferred consume, bare-token guard (the trimmed draft must
|
|
// still equal the command line when the modal closes).
|
|
setPendingConsume(session.sessionId, { kind: 'bare-token', token: LINE })
|
|
modalStoreOf(session.sessionId).set(true)
|
|
return Promise.resolve<'handled'>('handled')
|
|
},
|
|
})
|
|
}, 'dsh-context: /context command')
|
|
}
|