Files
dsh-context/src/client/command.ts
T
bowenliang123 bf4cdd1e58 feat: add /context slash command with a centered context modal
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).
2026-08-16 22:26:46 +08:00

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')
}