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).
This commit is contained in:
1 parent
6cb61a413b
commit
bf4cdd1e58
10 files changed
+558
-35
No files matched your search
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `/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')
|
||||
}
|
||||
Reference in new issue
Block a user